diff --git a/internal/ui/browser/browser.go b/internal/ui/browser/browser.go index 437e6e7..fc2a98b 100644 --- a/internal/ui/browser/browser.go +++ b/internal/ui/browser/browser.go @@ -11,6 +11,7 @@ import ( "github.com/TypicalAM/goread/internal/backend/rss" "github.com/TypicalAM/goread/internal/theme" "github.com/TypicalAM/goread/internal/ui/popup" + "github.com/TypicalAM/goread/internal/ui/popup/lollypops" "github.com/TypicalAM/goread/internal/ui/tab" "github.com/TypicalAM/goread/internal/ui/tab/category" "github.com/TypicalAM/goread/internal/ui/tab/feed" @@ -65,7 +66,8 @@ func (k *Keymap) SetEnabled(enabled bool) { // Model is used to store the state of the application type Model struct { - popup tea.Model + popup popup.Window + overlay popup.Overlay backend *backend.Backend style style msg string @@ -115,8 +117,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("Error fetching data in tab %d: %v \n", m.activeTab, msg.Err) updated, _ := m.tabs[m.activeTab].Update(msg) m.tabs[m.activeTab] = updated.(tab.Tab) - m.msg = fmt.Sprintf("%s: %s", msg.Description, unwrapErrs(msg.Err)) - return m, nil + errMsg := fmt.Sprintf("%s: %s", msg.Description, unwrapErrs(msg.Err)) + return m.showPopup(lollypops.NewError(m.style.colors, errMsg)) case overview.ChosenCategoryMsg: m.popup = nil @@ -124,19 +126,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.IsEdit { if err := m.backend.Rss.UpdateCategory(msg.OldName, msg.Name, msg.Desc); err != nil { - m.msg = fmt.Sprintf("Error updating category: %s", unwrapErrs(err)) - } else { - m.msg = fmt.Sprintf("Updated category %s", msg.Name) - } - } else { - if err := m.backend.Rss.AddCategory(msg.Name, msg.Desc); err != nil { - m.msg = fmt.Sprintf("Error adding category: %s", unwrapErrs(err)) - } else { - m.msg = fmt.Sprintf("Added category %s", msg.Name) + errMsg := fmt.Sprintf("Error updating category: %s", unwrapErrs(err)) + m, cmd := m.showPopup(lollypops.NewError(m.style.colors, errMsg)) + return m, tea.Sequence(cmd, m.backend.FetchCategories("")) } + + m.msg = fmt.Sprintf("Updated category %s", msg.Name) + return m, m.backend.FetchCategories("") + } + + if err := m.backend.Rss.AddCategory(msg.Name, msg.Desc); err != nil { + errMsg := fmt.Sprintf("Error adding category: %s", unwrapErrs(err)) + m, cmd := m.showPopup(lollypops.NewError(m.style.colors, errMsg)) + return m, tea.Sequence(cmd, m.backend.FetchCategories("")) } - log.Println(m.msg) + m.msg = fmt.Sprintf("Added category %s", msg.Name) return m, m.backend.FetchCategories("") case category.ChosenFeedMsg: @@ -145,56 +150,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.IsEdit { if err := m.backend.Rss.UpdateFeed(msg.Parent, msg.OldName, msg.Name, msg.URL); err != nil { - m.msg = fmt.Sprintf("Error updating feed: %s", unwrapErrs(err)) - } else { - m.msg = fmt.Sprintf("Updated feed %s", msg.Name) - } - } else { - if err := m.backend.Rss.AddFeed(msg.Parent, msg.Name, msg.URL); err != nil { - m.msg = fmt.Sprintf("Error adding feed: %s", unwrapErrs(err)) - } else { - m.msg = fmt.Sprintf("Added feed %s", msg.Name) + errMsg := fmt.Sprintf("Error updating feed: %s", unwrapErrs(err)) + m, cmd := m.showPopup(lollypops.NewError(m.style.colors, errMsg)) + return m, tea.Batch(cmd, m.backend.FetchFeeds(msg.Parent)) } + + m.msg = fmt.Sprintf("Updated feed %s", msg.Name) + return m, m.backend.FetchFeeds(msg.Parent) } - log.Println(m.msg) + if err := m.backend.Rss.AddFeed(msg.Parent, msg.Name, msg.URL); err != nil { + errMsg := fmt.Sprintf("Error adding feed: %s", unwrapErrs(err)) + m, cmd := m.showPopup(lollypops.NewError(m.style.colors, errMsg)) + return m, tea.Batch(cmd, m.backend.FetchFeeds(msg.Parent)) + } + + m.msg = fmt.Sprintf("Added feed %s", msg.Name) return m, m.backend.FetchFeeds(msg.Parent) case tab.NewTabMsg: return m.createNewTab(msg) case backend.NewItemMsg: - bg := m.View() - width := m.width / 2 - height := 17 + m.keymap.SetEnabled(false) switch msg.Sender.(type) { case overview.Model: - m.popup = overview.NewPopup(m.style.colors, bg, width, height, "", "") + return m.showPopup(overview.NewPopup(m.style.colors, "", "")) case category.Model: - m.popup = category.NewPopup(m.style.colors, bg, width, height, "", "", msg.Sender.Title()) + return m.showPopup(category.NewPopup(m.style.colors, "", "", msg.Sender.Title())) case feed.Model: } - m.keymap.SetEnabled(false) - return m, m.popup.Init() + return m, nil case backend.EditItemMsg: - bg := m.View() - width := m.width / 2 - height := 17 oldName, oldDesc := msg.OldFields[0], msg.OldFields[1] + m.keymap.SetEnabled(false) switch msg.Sender.(type) { case overview.Model: - m.popup = overview.NewPopup(m.style.colors, bg, width, height, oldName, oldDesc) + return m.showPopup(overview.NewPopup(m.style.colors, oldName, oldDesc)) case category.Model: - m.popup = category.NewPopup(m.style.colors, bg, width, height, oldName, oldDesc, msg.Sender.Title()) + return m.showPopup(category.NewPopup(m.style.colors, oldName, oldDesc, msg.Sender.Title())) case feed.Model: } - m.keymap.SetEnabled(false) - return m, m.popup.Init() + return m, nil case backend.DeleteItemMsg: return m.deleteItem(msg) @@ -211,14 +213,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case backend.MakeChoiceMsg: - bg := m.View() - width := m.width / 2 - m.popup = popup.NewChoice(m.style.colors, bg, width, msg.Question, msg.Default) + return m.showPopup(lollypops.NewChoice(m.style.colors, msg.Question, msg.Default)) - m.keymap.SetEnabled(false) - return m, m.popup.Init() - - case popup.ChoiceResultMsg: + case lollypops.ChoiceResultMsg, lollypops.ErrorResultMsg: m.keymap.SetEnabled(true) m.popup = nil @@ -231,6 +228,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tabs[i] = m.tabs[i].SetSize(m.width, m.height-5) } + // Delete the popup, update the overlay and rerender + if m.popup != nil { + return m.showPopup(m.popup) + } + case backend.SetEnableKeybindMsg: m.keymap.SetEnabled(bool(msg)) log.Println("Disabling keybinds, propagating") @@ -285,7 +287,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keymap.ShowHelp): - return m.showHelp() + return m.showPopup(newHelp(m.style.colors, m.FullHelp())) case key.Matches(msg, m.keymap.ToggleOfflineMode): return m.toggleOffline() @@ -294,7 +296,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If we are showing a popup, we need to update the popup if m.popup != nil { - m.popup, cmd = m.popup.Update(msg) + newPopup, cmd := m.popup.Update(msg) + m.popup = newPopup.(popup.Window) return m, cmd } @@ -314,7 +317,7 @@ func (m Model) View() string { } if m.popup != nil { - return m.popup.View() + return m.overlay.WrapView(m.popup.View()) } var b strings.Builder @@ -342,9 +345,9 @@ func (m Model) ShortHelp() []key.Binding { // FullHelp returns the full help for the browser. func (m Model) FullHelp() [][]key.Binding { - result := [][]key.Binding{m.ShortHelp()} - result = append(result, m.tabs[m.activeTab].FullHelp()...) - return result + browserHelp := [][]key.Binding{m.ShortHelp()} + childHelp := m.tabs[m.activeTab].FullHelp() + return prettifyHelp(browserHelp, childHelp) } // waitForSize waits for the window size to be set and loads the tab @@ -412,13 +415,15 @@ func (m Model) deleteItem(msg backend.DeleteItemMsg) (tea.Model, tea.Cmd) { case overview.Model: cmd = m.backend.FetchCategories("") if err := m.backend.Rss.RemoveCategory(msg.ItemName); err != nil { - m.msg = fmt.Sprintf("Error deleting category %s: %s", msg.ItemName, unwrapErrs(err)) + errMsg := fmt.Sprintf("Error deleting category %s: %s", msg.ItemName, unwrapErrs(err)) + return m.showPopup(lollypops.NewError(m.style.colors, errMsg)) } case category.Model: cmd = m.backend.FetchFeeds(m.tabs[m.activeTab].Title()) if err := m.backend.Rss.RemoveFeed(m.tabs[m.activeTab].Title(), msg.ItemName); err != nil { - m.msg = fmt.Sprintf("Error deleting feed %s: %s", msg.ItemName, unwrapErrs(err)) + errMsg := fmt.Sprintf("Error deleting feed %s: %s", msg.ItemName, unwrapErrs(err)) + return m.showPopup(lollypops.NewError(m.style.colors, errMsg)) } case feed.Model: @@ -426,11 +431,13 @@ func (m Model) deleteItem(msg backend.DeleteItemMsg) (tea.Model, tea.Cmd) { if msg.Sender.Title() == rss.DownloadedFeedsName { index, err := strconv.Atoi(msg.ItemName) if err != nil { - m.msg = fmt.Sprintf("Error deleting download %s: %s", msg.ItemName, unwrapErrs(err)) + errMsg := fmt.Sprintf("Error deleting download %s: %s", msg.ItemName, unwrapErrs(err)) + return m.showPopup(lollypops.NewError(m.style.colors, errMsg)) } if err := m.backend.Cache.RemoveFromDownloaded(index); err != nil { - m.msg = fmt.Sprintf("Error deleting download %s: %s", msg.ItemName, unwrapErrs(err)) + errMsg := fmt.Sprintf("Error deleting download %s: %s", msg.ItemName, unwrapErrs(err)) + return m.showPopup(lollypops.NewError(m.style.colors, errMsg)) } } } @@ -446,17 +453,6 @@ func (m Model) downloadItem(msg backend.DownloadItemMsg) (tea.Model, tea.Cmd) { return m, m.backend.DownloadItem(msg.FeedName, msg.Index) } -// showHelp shows the help menu as a popup. -func (m Model) showHelp() (tea.Model, tea.Cmd) { - bg := m.View() - width := m.width * 2 / 3 - height := 17 - - m.popup = newHelp(m.style.colors, bg, width, height, m.FullHelp()) - m.keymap.SetEnabled(false) - return m, nil -} - // toggleOffline toggles the offline mode func (m Model) toggleOffline() (tea.Model, tea.Cmd) { m.offline = !m.offline @@ -472,6 +468,16 @@ func (m Model) toggleOffline() (tea.Model, tea.Cmd) { return m, nil } +// showPopup tells the model to show the popup +func (m Model) showPopup(window popup.Window) (Model, tea.Cmd) { + m.popup = nil + background := m.View() + m.popup = window + width, height := m.popup.GetSize() + m.overlay = popup.NewOverlay(background, width, height) + return m, m.popup.Init() // TODO: Maybe don't call this while resizing +} + // renderTabBar renders the tab bar at the top of the screen func (m Model) renderTabBar() string { tabs := make([]string, len(m.tabs)) @@ -522,3 +528,77 @@ func unwrapErrs(err error) error { } return err } + +// prettifyHelp prettifies the help columns, removes the lower level bind if a higher one precedes it +func prettifyHelp(first, second [][]key.Binding) [][]key.Binding { + toDelSecond := make([]int, 0) + toDelThird := make([]int, 0) + + for _, elem := range first[0] { + // Second col + for idx2, elem2 := range second[0] { + if strIntersect(elem.Keys(), elem2.Keys()) { + toDelSecond = append(toDelSecond, idx2) + } + } + + // Third col + for idx2, elem2 := range second[1] { + if strIntersect(elem.Keys(), elem2.Keys()) { + toDelThird = append(toDelThird, idx2) + } + } + } + + second[0] = deleteBinds(second[0], toDelSecond) + second[1] = deleteBinds(second[1], toDelThird) + + for _, elem := range second[0] { + // Third col + for idx2, elem2 := range second[1] { + if strIntersect(elem.Keys(), elem2.Keys()) { + toDelThird = append(toDelThird, idx2) + } + } + } + + second[1] = deleteBinds(second[1], toDelThird) + return append(first, second...) +} + +// deleteBinds removes items from a bind slice via a list of indices +func deleteBinds(arr []key.Binding, indices []int) []key.Binding { + if len(indices) == 0 { + return arr + } + + result := make([]key.Binding, 0, len(arr)-len(indices)) + for idx, elem := range arr { + add := true + for _, toDel := range indices { + if idx == toDel { + add = false + break + } + } + + if add { + result = append(result, elem) + } + } + + return result +} + +// strIntersect checks if two string slices have a non-empty intersection +func strIntersect(first, second []string) bool { + for _, elem := range first { + for _, elem2 := range second { + if elem == elem2 { + return true + } + } + } + + return false +} diff --git a/internal/ui/browser/help.go b/internal/ui/browser/help.go index 3bf30ba..b127fbc 100644 --- a/internal/ui/browser/help.go +++ b/internal/ui/browser/help.go @@ -1,36 +1,58 @@ package browser import ( + "strings" + "github.com/TypicalAM/goread/internal/theme" "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/muesli/ansi" ) // Help is a popup that displays the help page. type Help struct { + border popup.TitleBorder help help.Model - style helpStyle + box lipgloss.Style keyBinds [][]key.Binding - overlay popup.Overlay + width int + height int } // newHelp returns a new Help popup. -func newHelp(colors *theme.Colors, bgRaw string, width, height int, binds [][]key.Binding) *Help { - style := newHelpStyle(colors, width, height) - help := help.New() - help.Styles = style.help +func newHelp(colors *theme.Colors, binds [][]key.Binding) *Help { + helpModel := help.New() + helpModel.Styles = help.Styles{} + helpModel.Styles.FullDesc = lipgloss.NewStyle(). + Foreground(colors.Text) + helpModel.Styles.FullKey = lipgloss.NewStyle(). + Foreground(colors.Color2) + helpModel.Styles.FullSeparator = lipgloss.NewStyle(). + Foreground(colors.TextDark) + + rendered := helpModel.FullHelpView(binds) + width := ansi.PrintableRuneWidth(rendered[:strings.IndexRune(rendered, '\n')-1]) + 6 + height := strings.Count(rendered, "\n") + 5 + border := popup.NewTitleBorder("Help", width, height, colors.Color1, lipgloss.NormalBorder()) return &Help{ - help: help, - style: style, + help: helpModel, + border: border, + box: lipgloss.NewStyle().Margin(1, 2, 1, 4), keyBinds: binds, - overlay: popup.NewOverlay(bgRaw, width, height), + width: width, + height: height, } } +// GetSize returns the size of the popup. +func (h Help) GetSize() (width int, height int) { + return h.width, h.height +} + // Init initializes the popup. func (h Help) Init() tea.Cmd { return nil @@ -43,8 +65,6 @@ func (h Help) Update(_ tea.Msg) (tea.Model, tea.Cmd) { // View renders the popup. func (h Help) View() string { - return h.overlay.WrapView(h.style.box.Render(lipgloss.JoinVertical(lipgloss.Center, - h.style.title.Render("Help"), - h.help.FullHelpView(h.keyBinds), - ))) + list := h.box.Render(h.help.FullHelpView(h.keyBinds)) + return h.border.Render(list) } diff --git a/internal/ui/browser/help_style.go b/internal/ui/browser/help_style.go deleted file mode 100644 index 295c9d2..0000000 --- a/internal/ui/browser/help_style.go +++ /dev/null @@ -1,40 +0,0 @@ -package browser - -import ( - "github.com/TypicalAM/goread/internal/theme" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/lipgloss" -) - -// helpStyle is the style for the help popup. -type helpStyle struct { - help help.Styles - title lipgloss.Style - box lipgloss.Style -} - -// newHelpStyle creates a new style for the help popup title and the help model. -func newHelpStyle(colors *theme.Colors, width, height int) helpStyle { - styles := help.Styles{} - styles.FullDesc = lipgloss.NewStyle(). - Foreground(colors.Text) - styles.FullKey = lipgloss.NewStyle(). - Foreground(colors.Color2) - styles.FullSeparator = lipgloss.NewStyle(). - Foreground(colors.TextDark) - - return helpStyle{ - help: styles, - title: lipgloss.NewStyle(). - Align(lipgloss.Center). - Margin(1, 0). - Width(width - 2). - Foreground(colors.Text). - Italic(true), - box: lipgloss.NewStyle(). - Width(width - 2). - Height(height - 2). - Border(lipgloss.NormalBorder()). - BorderForeground(colors.Color1), - } -} diff --git a/internal/ui/popup/fancy_border.go b/internal/ui/popup/fancy_border.go new file mode 100644 index 0000000..f684b46 --- /dev/null +++ b/internal/ui/popup/fancy_border.go @@ -0,0 +1,64 @@ +package popup + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// TitleBorder creates a fancy border style to wrap a popup +type TitleBorder struct { + bottomBorder lipgloss.Style + topBorder string + + text string + borderType lipgloss.Border + color lipgloss.Color +} + +// NewTitleBorder creates a new fancy border with a specific title +func NewTitleBorder(text string, width, height int, color lipgloss.Color, border lipgloss.Border) TitleBorder { + tb := TitleBorder{ + text: strings.Clone(text), + borderType: border, + color: color, + } + + tb.Resize(width, height) + return tb +} + +// Resize allows resizing the border and adjusting the top border +func (tb *TitleBorder) Resize(width, height int) { + tb.bottomBorder = lipgloss.NewStyle(). + Width(width-2). + Height(height-2). + Border(lipgloss.NormalBorder(), false, true, true, true). + BorderForeground(tb.color) + + textCopy := " " + strings.Clone(tb.text) + " " + if width-len(textCopy) < 2 { + textCopy = textCopy[:width-2] + } + + textWidth := len(textCopy) + if textWidth%2 == 1 { + textCopy += " " + textWidth++ + } + + fill := (width - 2 - textWidth) / 2 + + title := strings.Builder{} + title.WriteString(tb.borderType.TopLeft) + title.WriteString(strings.Repeat(tb.borderType.Top, fill+width%2)) + title.WriteString(textCopy) + title.WriteString(strings.Repeat(tb.borderType.Top, fill)) + title.WriteString(tb.borderType.TopRight) + tb.topBorder = lipgloss.NewStyle().Foreground(tb.color).Render(title.String()) +} + +// Render renders the contnent with the fancy border +func (tb TitleBorder) Render(view string) string { + return lipgloss.JoinVertical(lipgloss.Top, tb.topBorder, tb.bottomBorder.Render(view)) +} diff --git a/internal/ui/popup/choice.go b/internal/ui/popup/lollypops/choice.go similarity index 77% rename from internal/ui/popup/choice.go rename to internal/ui/popup/lollypops/choice.go index daa3c43..bd35afd 100644 --- a/internal/ui/popup/choice.go +++ b/internal/ui/popup/lollypops/choice.go @@ -1,4 +1,4 @@ -package popup +package lollypops import ( "github.com/TypicalAM/goread/internal/theme" @@ -13,26 +13,24 @@ type ChoiceResultMsg struct { // Choice is a popup that presents a yes/no choice to the user. type Choice struct { - style style + style choiceStyle question string - overlay Overlay selected bool + width int + height int } // NewChoice creates a new Choice popup. -func NewChoice(colors *theme.Colors, bgRaw string, width int, question string, defaultChoice bool) Choice { - optWidth := len(question) + 16 - if optWidth > width { - optWidth = width - } - +func NewChoice(colors *theme.Colors, question string, defaultChoice bool) Choice { + width := len(question) + 16 height := 7 return Choice{ - style: newStyle(colors, optWidth, height), - overlay: NewOverlay(bgRaw, optWidth, height), + style: newChoiceStyle(colors, width, height), question: question, selected: defaultChoice, + width: width, + height: height, } } @@ -79,9 +77,13 @@ func (c Choice) View() string { question := c.style.question.Render(c.question) buttons := lipgloss.JoinHorizontal(lipgloss.Top, okButton, cancelButton) ui := lipgloss.JoinVertical(lipgloss.Center, question, buttons) - dialog := lipgloss.Place(c.overlay.width-2, c.overlay.height-2, lipgloss.Center, lipgloss.Center, ui) + dialog := lipgloss.Place(c.width-2, c.height-2, lipgloss.Center, lipgloss.Center, ui) + return c.style.border.Render(dialog) +} - return c.overlay.WrapView(c.style.general.Render(dialog)) +// GetSize returns the size of the popup. +func (c Choice) GetSize() (width, height int) { + return c.width, c.height } // makeChoice returns a tea.Cmd that tells the parent model about the choice. diff --git a/internal/ui/popup/style.go b/internal/ui/popup/lollypops/choice_style.go similarity index 55% rename from internal/ui/popup/style.go rename to internal/ui/popup/lollypops/choice_style.go index 4c96e8e..686b481 100644 --- a/internal/ui/popup/style.go +++ b/internal/ui/popup/lollypops/choice_style.go @@ -1,20 +1,21 @@ -package popup +package lollypops import ( "github.com/TypicalAM/goread/internal/theme" + "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/lipgloss" ) -// style is the style of the choice popup -type style struct { +// choiceStyle is the style of the choice popup +type choiceStyle struct { + border popup.TitleBorder button lipgloss.Style activeButton lipgloss.Style question lipgloss.Style - general lipgloss.Style } -// newStyle creates a new style for the choice popup -func newStyle(colors *theme.Colors, width, height int) style { +// newChoiceStyle creates a new style for the choice popup +func newChoiceStyle(colors *theme.Colors, width, height int) choiceStyle { buttonStyle := lipgloss.NewStyle(). Foreground(colors.TextDark). Background(colors.BgDark). @@ -23,15 +24,7 @@ func newStyle(colors *theme.Colors, width, height int) style { activeButtonStyle := buttonStyle.Copy(). Foreground(colors.Text). - Background(colors.Color3). - Underline(true) - - general := lipgloss.NewStyle(). - Foreground(colors.Text). - Width(width - 2). - Height(height - 2). - Border(lipgloss.NormalBorder()). - BorderForeground(colors.Color1) + Background(colors.Color3) question := lipgloss.NewStyle(). Width(width). @@ -39,10 +32,10 @@ func newStyle(colors *theme.Colors, width, height int) style { Italic(true). Align(lipgloss.Center) - return style{ + return choiceStyle{ + border: popup.NewTitleBorder("Confirm choice", width, height, colors.Color1, lipgloss.NormalBorder()), button: buttonStyle, activeButton: activeButtonStyle, question: question, - general: general, } } diff --git a/internal/ui/popup/lollypops/error.go b/internal/ui/popup/lollypops/error.go new file mode 100644 index 0000000..309705e --- /dev/null +++ b/internal/ui/popup/lollypops/error.go @@ -0,0 +1,64 @@ +package lollypops + +import ( + "github.com/TypicalAM/goread/internal/theme" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ErrorResultMsg is the message sent when the user presses ok +type ErrorResultMsg struct{} + +// AppError is a popup that presents an error to the user. +type AppError struct { + style errorStyle + msg string + width int + height int +} + +// NewError creates a new error popup. +func NewError(colors *theme.Colors, message string) AppError { + width := len(message) + 16 + height := 7 + + return AppError{ + style: newErrorStyle(colors, width, height), + msg: message, + width: width, + height: height, + } +} + +// Init initializes the popup. +func (ae AppError) Init() tea.Cmd { + return nil +} + +// Update handles messages. +func (ae AppError) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "enter" { + return ae, ae.confirm() + } + + return ae, nil +} + +// View renders the popup. +func (ae AppError) View() string { + button := ae.style.activeButton.Render("OK") + msg := ae.style.msg.Render(ae.msg) + ui := lipgloss.JoinVertical(lipgloss.Center, msg, button) + dialog := lipgloss.Place(ae.width-2, ae.height-2, lipgloss.Center, lipgloss.Center, ui) + return ae.style.border.Render(dialog) +} + +// GetSize returns the size of the popup. +func (ae AppError) GetSize() (width, height int) { + return ae.width, ae.height +} + +// confirm returns a tea.Cmd that tells the parent model about the confirmation. +func (ae AppError) confirm() tea.Cmd { + return func() tea.Msg { return ErrorResultMsg{} } +} diff --git a/internal/ui/popup/lollypops/error_style.go b/internal/ui/popup/lollypops/error_style.go new file mode 100644 index 0000000..19e9461 --- /dev/null +++ b/internal/ui/popup/lollypops/error_style.go @@ -0,0 +1,39 @@ +package lollypops + +import ( + "github.com/TypicalAM/goread/internal/theme" + "github.com/TypicalAM/goread/internal/ui/popup" + "github.com/charmbracelet/lipgloss" +) + +// errorStyle is the style of the error popup +type errorStyle struct { + border popup.TitleBorder + activeButton lipgloss.Style + msg lipgloss.Style +} + +// newErrorStyle creates a new style for the error popup +func newErrorStyle(colors *theme.Colors, width, height int) errorStyle { + errorColor := lipgloss.Color("#f08ca8") + buttonStyle := lipgloss.NewStyle(). + Foreground(colors.TextDark). + Background(colors.BgDark). + Padding(0, 2). + Margin(0, 1) + + activeButtonStyle := buttonStyle.Copy(). + Foreground(colors.Text). + Background(colors.Color3) + + msg := lipgloss.NewStyle(). + Width(width). + Margin(1, 0). + Align(lipgloss.Center) + + return errorStyle{ + border: popup.NewTitleBorder("Error", width, height, errorColor, lipgloss.NormalBorder()), + activeButton: activeButtonStyle, + msg: msg, + } +} diff --git a/internal/ui/popup/overlay.go b/internal/ui/popup/overlay.go new file mode 100644 index 0000000..98ecb4f --- /dev/null +++ b/internal/ui/popup/overlay.go @@ -0,0 +1,107 @@ +package popup + +import ( + "strings" + + "github.com/muesli/ansi" +) + +// Overlay allows you to overlay text on top of a background and achieve a popup. +type Overlay struct { + textAbove string + textBelow string + rowPrefix []string + rowSuffix []string + width int + height int +} + +// NewOverlay creates a new overlay and computes the necessary indices. +func NewOverlay(bgRaw string, width, height int) Overlay { + bg := strings.Split(bgRaw, "\n") + bgWidth := ansi.PrintableRuneWidth(bg[0]) + bgHeight := len(bg) + + if height > bgHeight { + height = bgHeight + } + if width > bgWidth { + width = bgWidth + } + + startRow := (bgHeight - height) / 2 + startCol := (bgWidth - width) / 2 + + rowPrefix := make([]string, height) + rowSuffix := make([]string, height) + + for i, text := range bg[startRow : startRow+height] { + popupStart := findPrintIndex(text, startCol) + popupEnd := findPrintIndex(text, startCol+width) + + if popupStart != -1 { + rowPrefix[i] = text[:popupStart] + } else { + rowPrintable := ansi.PrintableRuneWidth(text) + rowPrefix[i] = text + strings.Repeat(" ", startCol-rowPrintable) + } + + if popupEnd != -1 { + rowSuffix[i] = text[popupEnd:] + } else { + rowSuffix[i] = "" + } + } + + prefix := strings.Join(bg[:startRow], "\n") + suffix := strings.Join(bg[startRow+height:], "\n") + + return Overlay{ + rowPrefix: rowPrefix, + rowSuffix: rowSuffix, + width: width, + height: height, + textAbove: prefix, + textBelow: suffix, + } +} + +// WrapView overlays the given text on top of the background. +// TODO: Maybe handle the box here. It's a bit weird to have to do it in the view. +func (p Overlay) WrapView(view string) string { + var b strings.Builder + b.WriteString(p.textAbove) + b.WriteRune('\n') + + lines := strings.Split(view, "\n") + for i := 0; i < len(lines) && i < p.height; i++ { + b.WriteString(p.rowPrefix[i]) + b.WriteString(lines[i]) + b.WriteString(p.rowSuffix[i]) + b.WriteRune('\n') + } + + b.WriteString(p.textBelow) + return b.String() +} + +// Width returns the width of the popup window. +func (p Overlay) Width() int { + return p.width +} + +// Height returns the height of the popup window. +func (p Overlay) Height() int { + return p.height +} + +// findPrintIndex finds the print index, that is what string index corresponds to the given printable rune index. +func findPrintIndex(str string, index int) int { + for i := len(str) - 1; i >= 0; i-- { + if ansi.PrintableRuneWidth(str[:i]) == index { + return i + } + } + + return -1 +} diff --git a/internal/ui/popup/popup.go b/internal/ui/popup/popup.go index 98ecb4f..381562c 100644 --- a/internal/ui/popup/popup.go +++ b/internal/ui/popup/popup.go @@ -1,107 +1,10 @@ package popup -import ( - "strings" +import tea "github.com/charmbracelet/bubbletea" - "github.com/muesli/ansi" -) +// Window represents a popup window. +type Window interface { + tea.Model -// Overlay allows you to overlay text on top of a background and achieve a popup. -type Overlay struct { - textAbove string - textBelow string - rowPrefix []string - rowSuffix []string - width int - height int -} - -// NewOverlay creates a new overlay and computes the necessary indices. -func NewOverlay(bgRaw string, width, height int) Overlay { - bg := strings.Split(bgRaw, "\n") - bgWidth := ansi.PrintableRuneWidth(bg[0]) - bgHeight := len(bg) - - if height > bgHeight { - height = bgHeight - } - if width > bgWidth { - width = bgWidth - } - - startRow := (bgHeight - height) / 2 - startCol := (bgWidth - width) / 2 - - rowPrefix := make([]string, height) - rowSuffix := make([]string, height) - - for i, text := range bg[startRow : startRow+height] { - popupStart := findPrintIndex(text, startCol) - popupEnd := findPrintIndex(text, startCol+width) - - if popupStart != -1 { - rowPrefix[i] = text[:popupStart] - } else { - rowPrintable := ansi.PrintableRuneWidth(text) - rowPrefix[i] = text + strings.Repeat(" ", startCol-rowPrintable) - } - - if popupEnd != -1 { - rowSuffix[i] = text[popupEnd:] - } else { - rowSuffix[i] = "" - } - } - - prefix := strings.Join(bg[:startRow], "\n") - suffix := strings.Join(bg[startRow+height:], "\n") - - return Overlay{ - rowPrefix: rowPrefix, - rowSuffix: rowSuffix, - width: width, - height: height, - textAbove: prefix, - textBelow: suffix, - } -} - -// WrapView overlays the given text on top of the background. -// TODO: Maybe handle the box here. It's a bit weird to have to do it in the view. -func (p Overlay) WrapView(view string) string { - var b strings.Builder - b.WriteString(p.textAbove) - b.WriteRune('\n') - - lines := strings.Split(view, "\n") - for i := 0; i < len(lines) && i < p.height; i++ { - b.WriteString(p.rowPrefix[i]) - b.WriteString(lines[i]) - b.WriteString(p.rowSuffix[i]) - b.WriteRune('\n') - } - - b.WriteString(p.textBelow) - return b.String() -} - -// Width returns the width of the popup window. -func (p Overlay) Width() int { - return p.width -} - -// Height returns the height of the popup window. -func (p Overlay) Height() int { - return p.height -} - -// findPrintIndex finds the print index, that is what string index corresponds to the given printable rune index. -func findPrintIndex(str string, index int) int { - for i := len(str) - 1; i >= 0; i-- { - if ansi.PrintableRuneWidth(str[:i]) == index { - return i - } - } - - return -1 + GetSize() (width, height int) } diff --git a/internal/ui/tab/category/category.go b/internal/ui/tab/category/category.go index b3b46f5..13ab40a 100644 --- a/internal/ui/tab/category/category.go +++ b/internal/ui/tab/category/category.go @@ -5,7 +5,7 @@ import ( "github.com/TypicalAM/goread/internal/backend" "github.com/TypicalAM/goread/internal/theme" - "github.com/TypicalAM/goread/internal/ui/popup" + "github.com/TypicalAM/goread/internal/ui/popup/lollypops" "github.com/TypicalAM/goread/internal/ui/simplelist" "github.com/TypicalAM/goread/internal/ui/tab" "github.com/charmbracelet/bubbles/key" @@ -85,7 +85,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keymap.SetEnabled(bool(msg)) return m, nil - case popup.ChoiceResultMsg: + case lollypops.ChoiceResultMsg: if !msg.Result { return m, nil } diff --git a/internal/ui/tab/category/popup.go b/internal/ui/tab/category/popup.go index 6d25dd0..f7c0c6f 100644 --- a/internal/ui/tab/category/popup.go +++ b/internal/ui/tab/category/popup.go @@ -2,7 +2,6 @@ package category import ( "github.com/TypicalAM/goread/internal/theme" - "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -33,16 +32,19 @@ type Popup struct { oldName string oldURL string parent string - overlay popup.Overlay focused focusedField + editing bool + width int + height int } // NewPopup returns a new feed popup. -func NewPopup(colors *theme.Colors, bgRaw string, width, height int, - oldName, oldURL, parent string) Popup { +func NewPopup(colors *theme.Colors, oldName, oldURL, parent string) Popup { + width := 40 + height := 7 + + editing := oldName != "" || oldURL != "" - style := newPopupStyle(colors, width, height) - overlay := popup.NewOverlay(bgRaw, width, height) nameInput := textinput.New() nameInput.CharLimit = 30 nameInput.Prompt = "Name: " @@ -52,7 +54,14 @@ func NewPopup(colors *theme.Colors, bgRaw string, width, height int, urlInput.Width = width - 20 urlInput.Prompt = "URL: " - if oldName != "" || oldURL != "" { + style := popupStyle{} + if editing { + style = newPopupStyle(colors, width, height, "Edit feed") + } else { + style = newPopupStyle(colors, width, height, "New feed") + } + + if editing { nameInput.SetValue(oldName) urlInput.SetValue(oldURL) } @@ -60,13 +69,15 @@ func NewPopup(colors *theme.Colors, bgRaw string, width, height int, nameInput.Focus() return Popup{ - overlay: overlay, style: style, nameInput: nameInput, urlInput: urlInput, oldName: oldName, oldURL: oldURL, parent: parent, + editing: editing, + width: width, + height: height, } } @@ -122,13 +133,23 @@ func (p Popup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the popup. func (p Popup) View() string { - question := p.style.heading.Render("Choose a feed") - title := p.style.itemTitle.Render("New Feed") + itemText := "" + if p.editing { + itemText = "Your feed" + } else { + itemText = "New feed" + } + + itemTitle := p.style.itemTitle.Render(itemText) name := p.style.itemField.Render(p.nameInput.View()) url := p.style.itemField.Render(p.urlInput.View()) - item := p.style.item.Render(lipgloss.JoinVertical(lipgloss.Left, title, name, url)) - popup := lipgloss.JoinVertical(lipgloss.Left, question, item) - return p.overlay.WrapView(p.style.general.Render(popup)) + listItem := p.style.listItem.Render(lipgloss.JoinVertical(lipgloss.Left, itemTitle, name, url)) + return p.style.border.Render(listItem) +} + +// GetSize returns the size of the popup. +func (p Popup) GetSize() (width, height int) { + return p.width, p.height } // confirm creates a message that confirms the user's choice. diff --git a/internal/ui/tab/category/style.go b/internal/ui/tab/category/style.go index 8ef2768..8b04ec8 100644 --- a/internal/ui/tab/category/style.go +++ b/internal/ui/tab/category/style.go @@ -2,35 +2,25 @@ package category import ( "github.com/TypicalAM/goread/internal/theme" + "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/lipgloss" ) // popupStyle is the style of the popup window. type popupStyle struct { - general lipgloss.Style + border popup.TitleBorder heading lipgloss.Style - item lipgloss.Style + listItem lipgloss.Style itemTitle lipgloss.Style itemField lipgloss.Style } // newPopupStyle creates a new popup style. -func newPopupStyle(colors *theme.Colors, width, height int) popupStyle { - general := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Width(width - 2). - Height(height - 2). - Border(lipgloss.NormalBorder()). - BorderForeground(colors.Color1) - - heading := lipgloss.NewStyle(). - Margin(1, 0, 1, 0). - Width(width - 2). - Align(lipgloss.Center). - Italic(true) +func newPopupStyle(colors *theme.Colors, width, height int, headingText string) popupStyle { + border := popup.NewTitleBorder(headingText, width, height, colors.Color1, lipgloss.NormalBorder()) item := lipgloss.NewStyle(). - Margin(0, 4). + Margin(1, 4). PaddingLeft(1). Border(lipgloss.RoundedBorder(), false, false, false, true). BorderForeground(colors.Color3). @@ -43,9 +33,8 @@ func newPopupStyle(colors *theme.Colors, width, height int) popupStyle { Foreground(colors.Color2) return popupStyle{ - general: general, - heading: heading, - item: item, + border: border, + listItem: item, itemTitle: itemTitle, itemField: itemField, } diff --git a/internal/ui/tab/feed/feed.go b/internal/ui/tab/feed/feed.go index ad2b95f..8f12d06 100644 --- a/internal/ui/tab/feed/feed.go +++ b/internal/ui/tab/feed/feed.go @@ -7,7 +7,7 @@ import ( "github.com/TypicalAM/goread/internal/backend" "github.com/TypicalAM/goread/internal/theme" - "github.com/TypicalAM/goread/internal/ui/popup" + "github.com/TypicalAM/goread/internal/ui/popup/lollypops" "github.com/TypicalAM/goread/internal/ui/tab" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -127,7 +127,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keymap.SetEnabled(bool(msg)) return m, nil - case popup.ChoiceResultMsg: + case lollypops.ChoiceResultMsg: if !msg.Result { return m, nil } @@ -383,10 +383,22 @@ func (m Model) ShortHelp() []key.Binding { // FullHelp returns the full help for the tab func (m Model) FullHelp() [][]key.Binding { - if !m.viewportOpen && m.viewportFocused { - result := [][]key.Binding{m.ShortHelp()} - result = append(result, m.list.FullHelp()...) - return result + if !m.viewportFocused { + listHelp := make([]key.Binding, 0) + for _, bind := range m.list.ShortHelp() { + shouldAdd := true + for _, key := range bind.Keys() { + if key == "?" { + shouldAdd = false + } + } + + if shouldAdd { + listHelp = append(listHelp, bind) + } + } + + return [][]key.Binding{m.ShortHelp(), listHelp} } return [][]key.Binding{m.ShortHelp(), { diff --git a/internal/ui/tab/overview/popup.go b/internal/ui/tab/overview/popup.go index f7e9e8a..5641cd0 100644 --- a/internal/ui/tab/overview/popup.go +++ b/internal/ui/tab/overview/popup.go @@ -3,7 +3,6 @@ package overview import ( "github.com/TypicalAM/goread/internal/backend/rss" "github.com/TypicalAM/goread/internal/theme" - "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -33,14 +32,21 @@ type Popup struct { descInput textinput.Model style popupStyle oldName string - overlay popup.Overlay focused focusedField + editing bool + width int + height int + reserved bool } // NewPopup creates a new popup window in which the user can choose a new category. -func NewPopup(colors *theme.Colors, bgRaw string, width, height int, oldName, oldDesc string) Popup { - overlay := popup.NewOverlay(bgRaw, width, height) - style := newPopupStyle(colors, width, height) +func NewPopup(colors *theme.Colors, oldName, oldDesc string) Popup { + width := 46 + height := 14 + + editing := oldName != "" || oldDesc != "" + reserved := oldName == rss.AllFeedsName || oldName == rss.DownloadedFeedsName + nameInput := textinput.New() nameInput.CharLimit = 30 nameInput.Width = width - 15 @@ -49,22 +55,36 @@ func NewPopup(colors *theme.Colors, bgRaw string, width, height int, oldName, ol descInput.CharLimit = 30 descInput.Width = width - 22 descInput.Prompt = "Description: " - focusedField := allField - if oldName != "" || oldDesc != "" { + focused := allField + if oldName == rss.DownloadedFeedsName { + focused = downloadedField + } + + style := popupStyle{} + if editing { + style = newPopupStyle(colors, width, height, "Edit category") + } else { + style = newPopupStyle(colors, width, height, "New category") + } + + if editing && !reserved { nameInput.SetValue(oldName) descInput.SetValue(oldDesc) - focusedField = nameField + focused = nameField nameInput.Focus() } return Popup{ - overlay: overlay, style: style, nameInput: nameInput, descInput: descInput, oldName: oldName, - focused: focusedField, + focused: focused, + editing: editing, + width: width, + height: height, + reserved: reserved, } } @@ -78,6 +98,10 @@ func (p Popup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd if msg, ok := msg.(tea.KeyMsg); ok { + if p.reserved { + return p, nil + } + switch msg.String() { case "down", "tab": switch p.focused { @@ -91,6 +115,10 @@ func (p Popup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.nameInput.Blur() cmds = append(cmds, p.descInput.Focus()) case descField: + if p.editing { + return p, nil + } + p.focused = allField p.descInput.Blur() } @@ -103,6 +131,10 @@ func (p Popup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case downloadedField: p.focused = allField case nameField: + if p.editing { + return p, nil + } + p.focused = downloadedField p.nameInput.Blur() case descField: @@ -142,11 +174,10 @@ func (p Popup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the popup window. func (p Popup) View() string { - question := p.style.heading.Render("Choose a category") renderedChoices := make([]string, 3) titles := []string{rss.AllFeedsName, rss.DownloadedFeedsName, "New category"} - descs := []string{"All the feeds", "Saved Feeds", p.nameInput.View() + "\n" + p.descInput.View()} + descs := []string{"All available articles", "Downloaded articles", p.nameInput.View() + "\n" + p.descInput.View()} var focused int switch p.focused { @@ -175,8 +206,12 @@ func (p Popup) View() string { } toList := p.style.list.Render(lipgloss.JoinVertical(lipgloss.Top, renderedChoices...)) - popup := lipgloss.JoinVertical(lipgloss.Top, question, toList) - return p.overlay.WrapView(p.style.general.Render(popup)) + return p.style.border.Render(toList) +} + +// GetSize returns the size of the popup. +func (p Popup) GetSize() (width, height int) { + return p.width, p.height } // confirm creates a message that confirms the user's choice. diff --git a/internal/ui/tab/overview/style.go b/internal/ui/tab/overview/style.go index b04e46e..0533c8c 100644 --- a/internal/ui/tab/overview/style.go +++ b/internal/ui/tab/overview/style.go @@ -2,13 +2,13 @@ package overview import ( "github.com/TypicalAM/goread/internal/theme" + "github.com/TypicalAM/goread/internal/ui/popup" "github.com/charmbracelet/lipgloss" ) // popupStyle is the style of the popup window. type popupStyle struct { - general lipgloss.Style - heading lipgloss.Style + border popup.TitleBorder list lipgloss.Style choice lipgloss.Style choiceTitle lipgloss.Style @@ -19,22 +19,11 @@ type popupStyle struct { } // newPopupStyle creates a new popup style. -func newPopupStyle(colors *theme.Colors, width, height int) popupStyle { - general := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Width(width - 2). - Height(height - 2). - Border(lipgloss.NormalBorder()). - BorderForeground(colors.Color1) - - heading := lipgloss.NewStyle(). - Margin(1, 0, 1, 0). - Width(width - 2). - Align(lipgloss.Center). - Italic(true) +func newPopupStyle(colors *theme.Colors, width, height int, headingText string) popupStyle { + border := popup.NewTitleBorder(headingText, width, height, colors.Color1, lipgloss.NormalBorder()) list := lipgloss.NewStyle(). - Margin(0, 4). + Margin(1, 4). Width(width - 2). Height(10) @@ -60,8 +49,7 @@ func newPopupStyle(colors *theme.Colors, width, height int) popupStyle { Foreground(colors.Color2) return popupStyle{ - general: general, - heading: heading, + border: border, list: list, choice: choice, choiceTitle: choiceTitle, diff --git a/internal/ui/tab/overview/welcome.go b/internal/ui/tab/overview/welcome.go index b6ca7aa..d9356e2 100644 --- a/internal/ui/tab/overview/welcome.go +++ b/internal/ui/tab/overview/welcome.go @@ -5,7 +5,7 @@ import ( "github.com/TypicalAM/goread/internal/backend" "github.com/TypicalAM/goread/internal/theme" - "github.com/TypicalAM/goread/internal/ui/popup" + "github.com/TypicalAM/goread/internal/ui/popup/lollypops" "github.com/TypicalAM/goread/internal/ui/simplelist" "github.com/TypicalAM/goread/internal/ui/tab" "github.com/charmbracelet/bubbles/key" @@ -89,7 +89,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keymap.SetEnabled(bool(msg)) return m, nil - case popup.ChoiceResultMsg: + case lollypops.ChoiceResultMsg: if !msg.Result { return m, nil }