lots of fixes and additions:

- add color and color config support
- fix epub parsing
- add variable margin (left and right arrow keys)
- add help screen (hit `?`)
- add config file support
This commit is contained in:
2025-10-15 14:36:43 +02:00
parent 0d4c44ee11
commit 7544f8877a
12 changed files with 337 additions and 65 deletions

63
cmd/colors.go Normal file
View File

@@ -0,0 +1,63 @@
package cmd
import (
"github.com/charmbracelet/lipgloss"
)
type ColorSetting struct {
Title string `koanf:"title"`
Chapter string `koanf:"chapter"`
Body string `koanf:"body"`
}
type Colors struct {
Title lipgloss.Style
Chapter lipgloss.Style
Body lipgloss.Style
}
func SetColorconfig(defaultdark, defaultlight ColorSetting, conf *Config) Colors {
var defaults, user ColorSetting
switch conf.Darkmode {
case true:
defaults = defaultdark
user = conf.ColorDark
default:
defaults = defaultlight
user = conf.ColorLight
}
var colors Colors
var fg string
border := lipgloss.RoundedBorder()
border.Right = "├"
styletitle := lipgloss.NewStyle().BorderStyle(border).Padding(0, 1)
if user.Title != "" {
fg = user.Title
} else {
fg = defaults.Title
}
colors.Title = styletitle.Foreground(lipgloss.Color(fg))
if user.Chapter != "" {
fg = user.Chapter
} else {
fg = defaults.Chapter
}
colors.Chapter = lipgloss.NewStyle().Foreground(lipgloss.Color(fg))
if user.Body != "" {
fg = user.Body
} else {
fg = defaults.Body
}
colors.Body = lipgloss.NewStyle().Foreground(lipgloss.Color(fg))
return colors
}

View File

@@ -7,20 +7,29 @@ import (
"os"
"path/filepath"
"github.com/alecthomas/repr"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
flag "github.com/spf13/pflag"
)
const (
Version string = `v0.0.1`
Version string = `v0.0.2`
Usage string = `epuppy [-vd] <epub file>`
)
type Config struct {
Showversion bool `koanf:"version"` // -v
Debug bool `koanf:"debug"` // -d
StoreProgress bool `koanf:"store-progress"` // -s
Showversion bool `koanf:"version"` // -v
Debug bool `koanf:"debug"` // -d
StoreProgress bool `koanf:"store-progress"` // -s
Darkmode bool `koanf:"dark"` // -D
Config string `koanf:"config"` // -c
ColorDark ColorSetting `koanf:"colordark"` // comes from config file only
ColorLight ColorSetting `koanf:"colorlight"` // comes from config file only
Colors Colors // generated from user config file or internal defaults, respects dark mode
Document string
InitialProgress int // lines
}
@@ -41,12 +50,41 @@ func InitConfig(output io.Writer) (*Config, error) {
// parse commandline flags
flagset.BoolP("version", "v", false, "show program version")
flagset.BoolP("debug", "d", false, "enable debugging")
flagset.BoolP("dark", "D", false, "enable dark mode")
flagset.BoolP("store-progress", "s", false, "store reading progress")
flagset.StringP("config", "c", "", "read config from file")
if err := flagset.Parse(os.Args[1:]); err != nil {
return nil, fmt.Errorf("failed to parse program arguments: %w", err)
}
// generate a list of config files to try to load, including the
// one provided via -c, if any
var configfiles []string
configfile, _ := flagset.GetString("config")
home, _ := os.UserHomeDir()
if configfile != "" {
configfiles = []string{configfile}
} else {
configfiles = []string{
"/etc/epuppy.toml", "/usr/local/etc/epuppy.toml", // unix variants
filepath.Join(home, ".config", "epuppy", "config.toml"),
}
}
// Load the config file[s]
for _, cfgfile := range configfiles {
if path, err := os.Stat(cfgfile); !os.IsNotExist(err) {
if !path.IsDir() {
if err := kloader.Load(file.Provider(cfgfile), toml.Parser()); err != nil {
return nil, fmt.Errorf("error loading config file: %w", err)
}
}
} // else: we ignore the file if it doesn't exists
}
// command line setup
if err := kloader.Load(posflag.Provider(flagset, ".", kloader), nil); err != nil {
return nil, fmt.Errorf("error loading flags: %w", err)
@@ -63,6 +101,24 @@ func InitConfig(output io.Writer) (*Config, error) {
conf.Document = flagset.Args()[0]
}
if conf.Debug {
repr.Println(conf)
}
// setup color config
conf.Colors = SetColorconfig(
ColorSetting{ // Dark
Title: "#ff4500",
Chapter: "#ff4500",
Body: "#cdb79e",
},
ColorSetting{ // Light
Title: "#ff0000",
Chapter: "#8b0000",
Body: "#696969",
},
conf)
return conf, nil
}

View File

@@ -9,6 +9,8 @@ import (
"os"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -32,6 +34,11 @@ var (
viewstyle = lipgloss.NewStyle()
)
const (
MarginStep = 5
MinSize = 40
)
type Meta struct {
lines int
currentline int
@@ -39,13 +46,67 @@ type Meta struct {
document string
}
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Help key.Binding
Quit key.Binding
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right}, // first column
{k.Help, k.Quit}, // second column
}
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓", "move down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "decrease text width"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→", "increase text width"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
type Doc struct {
content string
title string
ready bool
viewport viewport.Model
initialwidth int
lastwidth int
margin int
marginMod bool
meta *Meta
config *Config
keys keyMap
help help.Model
}
func (m Doc) Init() tea.Cmd {
@@ -60,8 +121,21 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" {
switch {
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Left):
if m.lastwidth-(m.margin*2) >= MinSize {
m.margin += MarginStep
m.marginMod = true
}
case key.Matches(msg, m.keys.Right):
if m.margin >= MarginStep {
m.margin -= MarginStep
m.marginMod = true
}
}
case tea.WindowSizeMsg:
@@ -85,10 +159,11 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - verticalMarginHeight
m.viewport.SetContent(wordwrap.String(m.content, msg.Width))
}
}
m.Rewrap()
// Handle keyboard and mouse events in the viewport
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
@@ -96,6 +171,17 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// re-calculate word wrapping, also add left margin
func (m *Doc) Rewrap() {
if m.lastwidth != m.viewport.Width || m.marginMod {
m.viewport.SetContent(wordwrap.String(m.content, m.viewport.Width-(m.margin*2)))
m.lastwidth = m.viewport.Width
m.marginMod = false
m.viewport.Style = viewstyle.MarginLeft(m.margin)
}
}
func (m Doc) View() string {
if !m.ready {
return "\n Initializing..."
@@ -105,17 +191,23 @@ func (m Doc) View() string {
// FIXME: doesn't work correctly yet
m.meta.currentline = int(float64(m.meta.lines) * m.viewport.ScrollPercent())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
var helpView string
if m.help.ShowAll {
helpView = "\n" + m.help.View(m.keys)
}
return fmt.Sprintf("%s\n%s\n%s%s", m.headerView(), m.viewport.View(), m.footerView(), helpView)
}
func (m Doc) headerView() string {
title := titleStyle.Render(m.title)
title := m.config.Colors.Title.Render(m.title)
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m Doc) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}
@@ -148,7 +240,7 @@ func Pager(conf *Config, title, message string) (int, error) {
}
p := tea.NewProgram(
Doc{content: message, title: title, initialwidth: width, meta: &meta},
Doc{content: message, title: title, initialwidth: width, meta: &meta, config: conf, keys: keys},
tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer"
tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
)

View File

@@ -3,11 +3,11 @@ package cmd
import (
"fmt"
"io"
"log"
"os"
)
func Die(err error) int {
log.Fatal("Error: ", err.Error())
fmt.Fprintln(os.Stderr, "Error: ", err.Error())
return 1
}

View File

@@ -1,6 +1,7 @@
package cmd
import (
"fmt"
"strings"
"github.com/tlinden/epuppy/pkg/epub"
@@ -28,12 +29,18 @@ func View(conf *Config) (int, error) {
head.WriteString(" ")
}
for _, point := range book.Ncx.Points {
if len(point.Content.Body) > 0 {
buf.WriteString("### " + point.Content.Title)
// FIXME: since the switch to book.Files() in epub.Open() this
// returns invalid chapter numbering
chapter := 1
for _, content := range book.Content {
if len(content.Body) > 0 {
buf.WriteString(conf.Colors.Chapter.
Render(fmt.Sprintf("Chapter %d: %s", chapter, content.Title)))
buf.WriteString("\r\n\r\n")
buf.WriteString(point.Content.Body)
buf.WriteString(conf.Colors.Body.Render(content.Body))
buf.WriteString("\r\n\r\n\r\n\r\n")
chapter++
}
}