diff --git a/pkg/tagesschau/convert.go b/pkg/tagesschau/convert.go new file mode 100644 index 0000000..a8d8938 --- /dev/null +++ b/pkg/tagesschau/convert.go @@ -0,0 +1,57 @@ +package tagesschau + +import "strings" + +func ContentToParagraphs(content []Content) []string { + prevType := "text" + prevSection := false + paragraph := "" + var paragraphs []string + for _, c := range content { + switch c.Type { + case "text": + fallthrough + case "headline": + text := c.Value + if strings.Trim(text, " ") == "" { + continue + } + + // drop author information + if isHighlighted(text, "em") { + continue + } + + // remove hyperlinks from text + for { + startIdx := strings.Index(text, "") + if endIndex == -1 { + break + } + text = text[:startIdx+2] + text[endIndex+1:] + } + sec := isSection(text) + if (prevType != c.Type || sec || prevSection) && paragraph != "" { + paragraphs = append(paragraphs, paragraph) + paragraph = "" + } + paragraph += text + " " + prevSection = sec + } + prevType = c.Type + } + paragraphs = append(paragraphs, paragraph) + return paragraphs +} + +func isSection(text string) bool { + return isHighlighted(text, "strong") || isHighlighted(text, "em") +} + +func isHighlighted(text string, tag string) bool { + return strings.HasPrefix(text, "<"+tag+">") && strings.HasSuffix(text, "") +} diff --git a/pkg/tui/reader.go b/pkg/tui/reader.go new file mode 100644 index 0000000..43bbb25 --- /dev/null +++ b/pkg/tui/reader.go @@ -0,0 +1,104 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/zMoooooritz/nachrichten/pkg/config" + "github.com/zMoooooritz/nachrichten/pkg/util" +) + +type Reader struct { + style config.Style + isFocused bool + toplineText string + dateText string + viewport viewport.Model +} + +func NewReader(s config.Style) Reader { + return Reader{ + style: s, + viewport: viewport.New(0, 0), + } +} + +func (r *Reader) GotoTop() { + r.viewport.GotoTop() +} + +func (r *Reader) GotoBottom() { + r.viewport.GotoBottom() +} + +func (r *Reader) SetFocused(isFocused bool) { + r.isFocused = isFocused +} + +func (r *Reader) IsFocused() bool { + return r.isFocused +} + +func (r *Reader) SetDims(w, h int) { + r.viewport.Width = w + r.viewport.Height = h - lipgloss.Height(r.headerView()) - lipgloss.Height(r.footerView()) + r.viewport.YPosition = lipgloss.Height(r.headerView()) +} + +func (r *Reader) SetContent(paragraphs []string) { + repr := util.FormatParagraphs(paragraphs, r.viewport.Width, r.style) + r.viewport.SetContent(repr) +} + +func (r *Reader) SetHeaderContent(topline string, date string) { + r.toplineText = topline + r.dateText = date +} + +func (r Reader) Init() tea.Cmd { + return nil +} + +func (r Reader) Update(msg tea.Msg) (Reader, tea.Cmd) { + var cmd tea.Cmd + r.viewport, cmd = r.viewport.Update(msg) + return r, tea.Batch(cmd) +} + +func (r Reader) View() string { + return fmt.Sprintf("%s\n%s\n%s", r.headerView(), r.viewport.View(), r.footerView()) +} + +func (r Reader) headerView() string { + titleStyle := r.style.ReaderTitleInactiveStyle + lineStyle := r.style.InactiveStyle + dateStyle := r.style.ReaderInfoInactiveStyle + if r.isFocused { + titleStyle = r.style.ReaderTitleActiveStyle + lineStyle = r.style.ActiveStyle + dateStyle = r.style.ReaderInfoActiveStyle + } + + title := titleStyle.Render(r.toplineText) + date := dateStyle.Render(r.dateText) + line := lineStyle.Render(strings.Repeat("─", util.Max(0, r.viewport.Width-lipgloss.Width(title)-lipgloss.Width(date)))) + + return lipgloss.JoinHorizontal(lipgloss.Center, title, line, date) +} + +func (r Reader) footerView() string { + infoStyle := r.style.ReaderInfoInactiveStyle + lineStyle := r.style.InactiveStyle + if r.isFocused { + infoStyle = r.style.ReaderInfoActiveStyle + lineStyle = r.style.ActiveStyle + } + + info := infoStyle.Render(fmt.Sprintf("%3.f%%", r.viewport.ScrollPercent()*100)) + line := lineStyle.Render(strings.Repeat("─", util.Max(0, r.viewport.Width-lipgloss.Width(info)))) + + return lipgloss.JoinHorizontal(lipgloss.Center, line, info) +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index eb3eb9c..e3e5d9f 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -2,13 +2,11 @@ package tui import ( "fmt" - "strings" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/zMoooooritz/nachrichten/pkg/config" @@ -34,7 +32,7 @@ var ( ) type Model struct { - configuration config.Configuration + opener util.Opener news tagesschau.News keymap KeyMap style config.Style @@ -44,10 +42,8 @@ type Model struct { lists []list.Model listsActiveIndeces []int activeListIndex int - reader viewport.Model + reader Reader spinner spinner.Model - focus int - readerFocused bool width int height int } @@ -79,15 +75,14 @@ func InitialModel(c config.Configuration) Model { } m := Model{ - configuration: c, + opener: util.NewOpener(c), keymap: GetKeyMap(), style: style, ready: false, help: NewHelper(style), helpMode: helpMode, - reader: viewport.New(0, 0), + reader: NewReader(style), spinner: NewDotSpinner(), - focus: 0, lists: EmptyLists(style, 2), listsActiveIndeces: []int{}, activeListIndex: 0, @@ -119,6 +114,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.InitLists([][]tagesschau.NewsEntry{m.news.NationalNews, m.news.RegionalNews}) m.resizeLists() m.ready = true + m.updateDisplayedArticle() case tea.KeyMsg: switch { case key.Matches(msg, m.keymap.quit): @@ -133,32 +129,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.updateSizes(m.width, m.height) case key.Matches(msg, m.keymap.right): - m.readerFocused = true + m.reader.SetFocused(true) case key.Matches(msg, m.keymap.left): - m.readerFocused = false + m.reader.SetFocused(false) case key.Matches(msg, m.keymap.next): - m.readerFocused = false + m.reader.SetFocused(false) m.activeListIndex = (m.activeListIndex + 1) % len(m.lists) + m.updateDisplayedArticle() + case key.Matches(msg, m.keymap.prev): + m.reader.SetFocused(false) + m.activeListIndex = (len(m.lists) + m.activeListIndex - 1) % len(m.lists) + m.updateDisplayedArticle() case key.Matches(msg, m.keymap.start): - if m.readerFocused { + if m.reader.IsFocused() { m.reader.GotoTop() } case key.Matches(msg, m.keymap.end): - if m.readerFocused { + if m.reader.IsFocused() { m.reader.GotoBottom() } - case key.Matches(msg, m.keymap.prev): - m.readerFocused = false - m.activeListIndex = (len(m.lists) + m.activeListIndex - 1) % len(m.lists) case key.Matches(msg, m.keymap.open): - article := m.SelectedArticle() - _ = util.OpenUrl(config.TypeHTML, m.configuration, article.URL) + article := m.selectedArticle() + m.opener.OpenUrl(config.TypeHTML, article.URL) case key.Matches(msg, m.keymap.video): - article := m.SelectedArticle() - _ = util.OpenUrl(config.TypeVideo, m.configuration, article.Video.VideoURLs.Big) + article := m.selectedArticle() + m.opener.OpenUrl(config.TypeVideo, article.Video.VideoURLs.Big) case key.Matches(msg, m.keymap.shortNews): url, _ := tagesschau.GetShortNewsURL() - _ = util.OpenUrl(config.TypeVideo, m.configuration, url) + m.opener.OpenUrl(config.TypeVideo, url) } case tea.WindowSizeMsg: m.updateSizes(msg.Width, msg.Height) @@ -171,33 +169,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } - if m.readerFocused { + if m.reader.IsFocused() { m.reader, cmd = m.reader.Update(msg) cmds = append(cmds, cmd) } else { m.lists[m.activeListIndex], cmd = m.lists[m.activeListIndex].Update(msg) cmds = append(cmds, cmd) - m.listsActiveIndeces[m.activeListIndex] = m.lists[m.activeListIndex].Index() - m.reader.SetContent(util.ContentToText(m.SelectedArticle().Content, m.reader.Width, m.style)) + if m.listsActiveIndeces[m.activeListIndex] != m.lists[m.activeListIndex].Index() { + m.listsActiveIndeces[m.activeListIndex] = m.lists[m.activeListIndex].Index() + m.updateDisplayedArticle() + } } return m, tea.Batch(cmds...) } +func (m *Model) updateDisplayedArticle() { + article := m.selectedArticle() + text := tagesschau.ContentToParagraphs(article.Content) + m.reader.SetContent(text) + m.reader.SetHeaderContent(article.Topline, article.Date.Format(germanDateFormat)) +} + func (m *Model) updateSizes(width, height int) { m.width = width m.height = height - m.reader.YPosition = m.readerHeaderHeight() - m.resizeLists() - m.reader.Width, m.reader.Height = m.readerDims() + w, _ := m.listOuterDims() + m.reader.SetDims(m.width-w-6, m.height-m.helperHeight()) m.help.Width = m.width } func (m *Model) resizeLists() { - w, _ := m.listSelectorDims() + w, _ := m.listInnerDims() for i := range m.lists { m.lists[i].SetSize(m.listOuterDims()) m.lists[i].Title = lipgloss.PlaceHorizontal(w, lipgloss.Center, headerText) @@ -209,24 +215,11 @@ func (m Model) listOuterDims() (int, int) { return m.width / 3, m.height - m.helperHeight() - 5 } -func (m Model) listSelectorDims() (int, int) { +func (m Model) listInnerDims() (int, int) { w, h := m.listOuterDims() return w - 4, h } -func (m Model) readerDims() (int, int) { - lw, _ := m.listOuterDims() - return m.width - lw - 6, m.height - m.readerHeaderHeight() - m.readerFooterHeight() - m.helperHeight() -} - -func (m Model) readerHeaderHeight() int { - return lipgloss.Height(m.headerView("", "")) -} - -func (m Model) readerFooterHeight() int { - return lipgloss.Height(m.footerView()) -} - func (m Model) helperHeight() int { if m.helpMode > 0 { return 2 @@ -234,7 +227,7 @@ func (m Model) helperHeight() int { return 0 } -func (m Model) SelectedArticle() tagesschau.NewsEntry { +func (m Model) selectedArticle() tagesschau.NewsEntry { var article tagesschau.NewsEntry if m.activeListIndex == 0 { article = m.news.NationalNews[m.listsActiveIndeces[m.activeListIndex]] @@ -250,14 +243,13 @@ func (m Model) View() string { return screenCentered(m.width, m.height).Render(content) } - listHeader := m.listSelectorView([]string{nationalHeaderText, regionalHeaderText}, m.activeListIndex) + listHeader := m.listView([]string{nationalHeaderText, regionalHeaderText}, m.activeListIndex) listStyle := m.style.ListActiveStyle - if m.readerFocused { + if m.reader.IsFocused() { listStyle = m.style.ListInactiveStyle } list := listStyle.Render(lipgloss.JoinVertical(lipgloss.Left, listHeader, m.lists[m.activeListIndex].View())) - article := m.SelectedArticle() - reader := fmt.Sprintf("%s\n%s\n%s", m.headerView(article.Topline, article.Date.Format(germanDateFormat)), m.reader.View(), m.footerView()) + reader := m.reader.View() help := "" if m.helpMode > 0 { @@ -267,8 +259,8 @@ func (m Model) View() string { return lipgloss.JoinHorizontal(lipgloss.Top, list, reader) + help } -func (m Model) listSelectorView(names []string, activeIndex int) string { - width, _ := m.listSelectorDims() +func (m Model) listView(names []string, activeIndex int) string { + width, _ := m.listInnerDims() cellWidth := width / len(names) var widths []int for i := 0; i < len(names)-1; i++ { @@ -285,34 +277,3 @@ func (m Model) listSelectorView(names []string, activeIndex int) string { } return lipgloss.NewStyle().PaddingLeft(2).Render(result) } - -func (m Model) headerView(name string, date string) string { - titleStyle := m.style.ReaderTitleInactiveStyle - lineStyle := m.style.InactiveStyle - dateStyle := m.style.ReaderInfoInactiveStyle - if m.readerFocused { - titleStyle = m.style.ReaderTitleActiveStyle - lineStyle = m.style.ActiveStyle - dateStyle = m.style.ReaderInfoActiveStyle - } - - title := titleStyle.Render(name) - date = dateStyle.Render(date) - line := lineStyle.Render(strings.Repeat("─", util.Max(0, m.reader.Width-lipgloss.Width(title)-lipgloss.Width(date)))) - - return lipgloss.JoinHorizontal(lipgloss.Center, title, line, date) -} - -func (m Model) footerView() string { - infoStyle := m.style.ReaderInfoInactiveStyle - lineStyle := m.style.InactiveStyle - if m.readerFocused { - infoStyle = m.style.ReaderInfoActiveStyle - lineStyle = m.style.ActiveStyle - } - - info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.reader.ScrollPercent()*100)) - line := lineStyle.Render(strings.Repeat("─", util.Max(0, m.reader.Width-lipgloss.Width(info)))) - - return lipgloss.JoinHorizontal(lipgloss.Center, line, info) -} diff --git a/pkg/util/opener.go b/pkg/util/opener.go index 2d7f093..6a88055 100644 --- a/pkg/util/opener.go +++ b/pkg/util/opener.go @@ -7,27 +7,39 @@ import ( "github.com/zMoooooritz/nachrichten/pkg/config" ) -func OpenUrl(t config.ResourceType, c config.Configuration, url string) error { +type Opener struct { + configuration config.Configuration +} + +func NewOpener(configuration config.Configuration) Opener { + return Opener{ + configuration: configuration, + } +} + +func (o Opener) OpenUrl(t config.ResourceType, url string) { var appConfig config.ApplicationConfig switch t { case config.TypeImage: - appConfig = c.AppConfig.Image + appConfig = o.configuration.AppConfig.Image case config.TypeAudio: - appConfig = c.AppConfig.Audio + appConfig = o.configuration.AppConfig.Audio case config.TypeVideo: - appConfig = c.AppConfig.Video + appConfig = o.configuration.AppConfig.Video case config.TypeHTML: - appConfig = c.AppConfig.HTML + appConfig = o.configuration.AppConfig.HTML default: - return defaultOpenUrl(url) + defaultOpenUrl(url) + return } cConfig := appConfig cConfig.Args = append([]string(nil), appConfig.Args...) if cConfig.Path == "" || len(cConfig.Args) == 0 { - return defaultOpenUrl(url) + defaultOpenUrl(url) + return } for i, arg := range cConfig.Args { @@ -35,10 +47,10 @@ func OpenUrl(t config.ResourceType, c config.Configuration, url string) error { cConfig.Args[i] = url } } - return exec.Command(cConfig.Path, cConfig.Args...).Start() + _ = exec.Command(cConfig.Path, cConfig.Args...).Start() } -func defaultOpenUrl(url string) error { +func defaultOpenUrl(url string) { var cmd string var args []string @@ -52,5 +64,5 @@ func defaultOpenUrl(url string) error { cmd = "xdg-open" } args = append(args, url) - return exec.Command(cmd, args...).Start() + _ = exec.Command(cmd, args...).Start() } diff --git a/pkg/util/util.go b/pkg/util/util.go index bcbaa6e..4564b91 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -7,10 +7,9 @@ import ( "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/zMoooooritz/nachrichten/pkg/config" - "github.com/zMoooooritz/nachrichten/pkg/tagesschau" ) -func ContentToText(content []tagesschau.Content, width int, s config.Style) string { +func FormatParagraphs(paragraphs []string, width int, s config.Style) string { converter := md.NewConverter("", true, nil) renderer, _ := glamour.NewTermRenderer( glamour.WithAutoStyle(), @@ -18,49 +17,6 @@ func ContentToText(content []tagesschau.Content, width int, s config.Style) stri glamour.WithStyles(s.ReaderStyle), ) - prevType := "text" - prevSection := false - paragraph := "" - var paragraphs []string - for _, c := range content { - switch c.Type { - case "text": - fallthrough - case "headline": - text := c.Value - if strings.Trim(text, " ") == "" { - continue - } - - // drop author information - if isHighlighted(text, "em") { - continue - } - - // remove hyperlinks from text - for { - startIdx := strings.Index(text, "") - if endIndex == -1 { - break - } - text = text[:startIdx+2] + text[endIndex+1:] - } - sec := isSection(text) - if (prevType != c.Type || sec || prevSection) && paragraph != "" { - paragraphs = append(paragraphs, paragraph) - paragraph = "" - } - paragraph += text + " " - prevSection = sec - } - prevType = c.Type - } - paragraphs = append(paragraphs, paragraph) - result := "" for _, p := range paragraphs { text, _ := converter.ConvertString(p) @@ -70,14 +26,6 @@ func ContentToText(content []tagesschau.Content, width int, s config.Style) stri return padText(result, width) } -func isSection(text string) bool { - return isHighlighted(text, "strong") || isHighlighted(text, "em") -} - -func isHighlighted(text string, tag string) bool { - return strings.HasPrefix(text, "<"+tag+">") && strings.HasSuffix(text, "") -} - func padText(text string, width int) string { result := "" split := strings.Split(text, "\n")