diff --git a/cmd/colors.go b/cmd/colors.go new file mode 100644 index 0000000..4501a76 --- /dev/null +++ b/cmd/colors.go @@ -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 +} diff --git a/cmd/config.go b/cmd/config.go index 2d49d17..b0e53a9 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -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] ` ) 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 } diff --git a/cmd/pager.go b/cmd/pager.go index 449bae5..e4018c2 100644 --- a/cmd/pager.go +++ b/cmd/pager.go @@ -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 ) diff --git a/cmd/root.go b/cmd/root.go index ec758cb..1d8ae59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 } diff --git a/cmd/view.go b/cmd/view.go index 2414946..f5f59b3 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -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++ } } diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..8278fae --- /dev/null +++ b/config.toml @@ -0,0 +1,16 @@ +# color setting for dark mode +colordark = { + body = "#ffffff", + title = "#7cfc00", + chapter = "#ffff00" +} + +# color setting for light mode +colorlight = { + body = "#000000", + title = "#8470ff", + chapter = "#00008b" +} + +# always use dark mode +dark = true diff --git a/go.mod b/go.mod index 4456e6e..b4236d7 100644 --- a/go.mod +++ b/go.mod @@ -35,12 +35,16 @@ require ( require ( github.com/alecthomas/repr v0.5.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/toml v0.1.0 // indirect + github.com/knadh/koanf/providers/file v1.2.0 // indirect github.com/knadh/koanf/providers/posflag v1.0.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/reflow v0.3.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect golang.org/x/term v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 52bd41e..c540783 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -170,6 +172,10 @@ github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= +github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= +github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= +github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= @@ -237,6 +243,8 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/pkg/epub/book.go b/pkg/epub/book.go index 2934b71..17b8989 100644 --- a/pkg/epub/book.go +++ b/pkg/epub/book.go @@ -14,6 +14,7 @@ type Book struct { Opf Opf `json:"opf"` Container Container `json:"-"` Mimetype string `json:"-"` + Content []Content fd *zip.ReadCloser } @@ -49,7 +50,12 @@ func (p *Book) readXML(n string, v interface{}) error { } defer fd.Close() dec := xml.NewDecoder(fd) - return dec.Decode(v) + + if err := dec.Decode(v); err != nil { + return fmt.Errorf("XML decoder error %w", err) + } + + return nil } func (p *Book) readBytes(n string) ([]byte, error) { @@ -59,8 +65,12 @@ func (p *Book) readBytes(n string) ([]byte, error) { } defer fd.Close() - return io.ReadAll(fd) + data, err := io.ReadAll(fd) + if err != nil { + return nil, fmt.Errorf("failed to read contents from %s %w", n, err) + } + return data, nil } func (p *Book) open(n string) (io.ReadCloser, error) { @@ -69,5 +79,6 @@ func (p *Book) open(n string) (io.ReadCloser, error) { return f.Open() } } + return nil, fmt.Errorf("file %s not exist", n) } diff --git a/pkg/epub/content.go b/pkg/epub/content.go new file mode 100644 index 0000000..e062d31 --- /dev/null +++ b/pkg/epub/content.go @@ -0,0 +1,46 @@ +package epub + +import ( + "encoding/xml" + "fmt" + "strings" +) + +// Content nav-point content +type Content struct { + Src string `xml:"src,attr" json:"src"` + Empty bool + Body string + Title string + XML []byte +} + +func (c *Content) String(content []byte) error { + title := Title{} + + err := xml.Unmarshal(content, &title) + if err != nil { + if !strings.HasPrefix(err.Error(), "XML syntax error") { + return fmt.Errorf("XML parser error %w", err) + } + } + + c.Title = strings.TrimSpace(title.Content) + + txt := cleantitle.ReplaceAllString(string(content), "") + txt = cleanh1.ReplaceAllString(txt, "") + txt = cleanmarkup.ReplaceAllString(txt, "") + txt = cleanentities.ReplaceAllString(txt, " ") + txt = cleancomments.ReplaceAllString(txt, "") + + txt = strings.TrimSpace(txt) + + c.Body = cleanspace.ReplaceAllString(txt, "") + c.XML = content + + if len(c.Body) == 0 { + c.Empty = true + } + + return nil +} diff --git a/pkg/epub/ncx.go b/pkg/epub/ncx.go index 31e2afa..dcc7bc6 100644 --- a/pkg/epub/ncx.go +++ b/pkg/epub/ncx.go @@ -1,9 +1,7 @@ package epub import ( - "encoding/xml" "regexp" - "strings" ) var ( @@ -22,50 +20,10 @@ type Ncx struct { // NavPoint nav point type NavPoint struct { - Text string `xml:"navLabel>text" json:"text"` - Content Content `xml:"content" json:"content"` - Points []NavPoint `xml:"navPoint" json:"points"` + Text string `xml:"navLabel>text" json:"text"` + Points []NavPoint `xml:"navPoint" json:"points"` } type Title struct { Content string `xml:"head>title"` } - -// Content nav-point content -type Content struct { - Src string `xml:"src,attr" json:"src"` - Empty bool - Body string - Title string - XML []byte -} - -func (c *Content) String(content []byte) error { - title := Title{} - - err := xml.Unmarshal(content, &title) - if err != nil { - if !strings.HasPrefix(err.Error(), "XML syntax error") { - return err - } - } - - c.Title = title.Content - - txt := cleantitle.ReplaceAllString(string(content), "") - txt = cleanh1.ReplaceAllString(txt, "") - txt = cleanmarkup.ReplaceAllString(txt, "") - txt = cleanentities.ReplaceAllString(txt, " ") - txt = cleancomments.ReplaceAllString(txt, "") - - txt = strings.TrimSpace(txt) - - c.Body = cleanspace.ReplaceAllString(txt, "") - c.XML = content - - if len(c.Body) == 0 { - c.Empty = true - } - - return nil -} diff --git a/pkg/epub/open.go b/pkg/epub/open.go index d8a7d1a..a45c46c 100644 --- a/pkg/epub/open.go +++ b/pkg/epub/open.go @@ -2,6 +2,7 @@ package epub import ( "archive/zip" + "strings" ) // Open open a epub file @@ -34,19 +35,29 @@ func Open(fn string) (*Book, error) { for _, mf := range bk.Opf.Manifest { if mf.ID == bk.Opf.Spine.Toc { err = bk.readXML(bk.filename(mf.Href), &bk.Ncx) + if err != nil { + return &bk, err + } + break } } - for _, ncx := range bk.Ncx.Points { - content, err := bk.readBytes(bk.filename(ncx.Content.Src)) + for _, file := range bk.Files() { + content, err := bk.readBytes(file) if err != nil { return &bk, err } - if err := ncx.Content.String(content); err != nil { - return &bk, err + ct := Content{Src: file} + + if strings.Contains(string(content), "DOCTYPE") { + if err := ct.String(content); err != nil { + return &bk, err + } } + + bk.Content = append(bk.Content, ct) } return &bk, nil