package ui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) // modal is an overlay that takes over the view and the keyboard. // // Modals replace rather than float: laying content over a scrolling list is // fiddly, and a full replacement keeps the grid honest. `esc` always closes. type modal interface { Update(tea.Msg) (modal, tea.Cmd) View(width, height int) string } // keyDesc is one row of a keymap. type keyDesc struct{ key, desc string } // renderKeymap lays out key/description rows on a fixed row count so the box // does not change height as you switch tabs. func renderKeymap(items []keyDesc, rows int) string { var b strings.Builder for i := 0; i < rows; i++ { if i < len(items) { switch { case items[i].key == "" && items[i].desc != "": // A heading for the keys that follow. Aligned with the // descriptions it labels: flush left it sat directly under the // row above and read as that row wrapping onto a second line. b.WriteString(KeyField("", 12)) b.WriteString(Muted.Render(items[i].desc)) case items[i].key == "": // deliberate blank, for spacing default: b.WriteString(KeyField(items[i].key, 12)) b.WriteString(Desc.Render(" " + items[i].desc)) } } b.WriteString("\n") } return strings.TrimRight(b.String(), "\n") } // tabBar draws the tab strip with the active tab highlighted. func tabBar(titles []string, active int) string { parts := make([]string, 0, len(titles)) for i, title := range titles { label := " " + title + " " if i == active { parts = append(parts, TabActive.Render(label)) } else { parts = append(parts, TabIdle.Render(label)) } } return strings.Join(parts, " ") } // place centres a box on the screen and guarantees it cannot exceed the // terminal. // // lipgloss.Place happily returns a box wider -- and taller -- than what it was // given, which makes every line wrap and pushes the top of the screen away. // The individual builders truncate, but this is the backstop that makes // overflow impossible rather than merely unlikely. // // It clipped only the width, and PlaceVertical returns a too-tall string // untouched: the help box is seventeen lines whatever the height, so on a // twelve-line terminal it simply overflowed. Every overlay relied on this. func place(box string, width, height int) string { placed := lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) if width <= 0 && height <= 0 { return placed } lines := strings.Split(placed, "\n") if width > 0 { for i, line := range lines { if ansi.StringWidth(line) > width { lines[i] = ansi.Truncate(line, width, "") } } } if height > 0 && len(lines) > height { // Keep the centre of the box rather than its top: the middle carries // the text, the edges carry the border. drop := len(lines) - height top := drop / 2 lines = lines[top : top+height] } return strings.Join(lines, "\n") } // -------------------------------------------------------------------------- // help // -------------------------------------------------------------------------- // helpModal is the keymap, over whichever screen asked for it. // // The two dashboards answer to different keys: nothing about skipping a // package means anything mid-transaction, and nothing about stopping a build // means anything before one has started. Showing both sets together made each // screen's help a third wrong. type helpModal struct { tab int tabs []string keys [][]keyDesc } // The two dashboards answer to different keys, so they get different keymaps. // Nothing about skipping a package means anything mid-transaction, and nothing // about stopping a build means anything before one has started; showing both // sets together made each screen's help a third wrong. // -- the main dashboard, before anything is applied ------------------------ var listHelpTabs = []string{"NAV", "DECIDE", "VIEW", "META"} var listHelpKeys = [][]keyDesc{ { {"j / k", "move down / up"}, {"gg / G", "jump to top / bottom"}, {"ctrl+d / u", "half page"}, {"enter", "collapse / expand / open"}, {"tab", "cycle tabs"}, {"esc", "close popups"}, {"q", "quit"}, }, { {"space", "skip for this run"}, {"i", "ignore permanently"}, {"u", "undo the last decision"}, {"a", "review and apply"}, {"r", "reload the plan"}, {"v", "hide or show the dashboard"}, }, { {"enter", "collapse / expand / open"}, {"y", "copy the selected line"}, {"o", "open the diff in a pager"}, {"/", "filter by name"}, {"x", "clear the filter"}, }, { {"?", "toggle this help"}, {"y", "copy the selected line"}, {"c", "acknowledge an advisory"}, {"C", "clear acknowledgements"}, {"R", "run a check's suggested command"}, {"n", "mark the arch news as read"}, }, } // -- the run dashboard, once a transaction is under way -------------------- var runHelpTabs = []string{"NAV", "OUTPUT"} var runHelpKeys = [][]keyDesc{ { {"j / k", "move between warnings"}, {"enter", "read the selected warning"}, {"d", "dismiss it for this session"}, {"v", "hide the dashboard, keep the feed"}, {"esc", "collapse, or go back once finished"}, {"q", "quit once the run has finished"}, {"ctrl+c", "stop the run; twice confirms"}, {"R", "finish what could not be built"}, }, { {"o", "the output, full screen"}, {"l", "the last few lines, inline"}, {"", ""}, {"", "inside the output view"}, {"/", "search"}, {"n / N", "next / previous match"}, {"j / k", "scroll"}, {"g / G", "top / bottom"}, {"f", "follow, or stop following"}, {"c", "copy the whole log"}, }, } func helpRows(keys [][]keyDesc) int { rows := 0 for _, tab := range keys { if len(tab) > rows { rows = len(tab) } } return rows } func newHelp() *helpModal { return &helpModal{tabs: listHelpTabs, keys: listHelpKeys} } func newRunHelp() *helpModal { return &helpModal{tabs: runHelpTabs, keys: runHelpKeys} } func (h *helpModal) Update(msg tea.Msg) (modal, tea.Cmd) { key, ok := msg.(tea.KeyMsg) if !ok { return h, nil } switch key.String() { case "q", "esc", "?", "enter", "ctrl+c": return nil, nil case "tab", "l", "right": h.tab = (h.tab + 1) % len(h.tabs) case "shift+tab", "h", "left": h.tab-- if h.tab < 0 { h.tab = len(h.tabs) - 1 } } return h, nil } func (h *helpModal) View(width, height int) string { var b strings.Builder b.WriteString(Title.Render("KEYMAP")) b.WriteString("\n\n") b.WriteString(tabBar(h.tabs, h.tab)) b.WriteString("\n\n") b.WriteString(renderKeymap(h.keys[h.tab], helpRows(h.keys))) b.WriteString("\n\n") b.WriteString(Desc.Render("[tab] switch • [esc] close")) box := Box.Render(b.String()) return place(box, width, height) } // -------------------------------------------------------------------------- // confirm // -------------------------------------------------------------------------- // confirmModal states exactly what is about to run, and refuses to run anything // when the plan would be a partial upgrade. type confirmModal struct { commands []string notes []string warnings []string } func newConfirm(commands, notes, warnings []string) *confirmModal { return &confirmModal{commands: commands, notes: notes, warnings: warnings} } // confirmedMsg and cancelledMsg carry the user's answer back to the model. type confirmedMsg struct{} type cancelledMsg struct{} func (c *confirmModal) Update(msg tea.Msg) (modal, tea.Cmd) { key, ok := msg.(tea.KeyMsg) if !ok { return c, nil } switch key.String() { case "y", "Y", "enter": return nil, func() tea.Msg { return confirmedMsg{} } case "n", "N", "q", "esc", "ctrl+c": return nil, func() tea.Msg { return cancelledMsg{} } } return c, nil } func (c *confirmModal) View(width, height int) string { var b strings.Builder b.WriteString(Title.Render("APPLY THIS PLAN?")) b.WriteString("\n\n") for i, cmd := range c.commands { b.WriteString(" " + Accent.Render(fmt.Sprintf("%d.", i+1)) + " " + Text.Render(cmd) + "\n") if i < len(c.notes) && c.notes[i] != "" { b.WriteString(" " + Muted.Render(c.notes[i]) + "\n") } } if len(c.warnings) > 0 { b.WriteString("\n") for _, warning := range c.warnings { b.WriteString(Warn.Render(" ! "+warning) + "\n") } } b.WriteString("\n") b.WriteString(Text.Render(" Apply? ") + Muted.Render("[y/N]")) box := Box.Render(strings.TrimRight(b.String(), "\n")) return place(box, width, height) } // -------------------------------------------------------------------------- // message // -------------------------------------------------------------------------- // messageModal is a one-line acknowledgement, used for actions that have no // visible result of their own. type messageModal struct { title string body []string } func newMessage(title string, body ...string) *messageModal { return &messageModal{title: title, body: body} } func (m *messageModal) Update(msg tea.Msg) (modal, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "q", "esc", "enter", " ", "ctrl+c": return nil, nil } } return m, nil } func (m *messageModal) View(width, height int) string { var b strings.Builder b.WriteString(Title.Render(m.title)) b.WriteString("\n\n") for _, line := range m.body { b.WriteString(line + "\n") } b.WriteString("\n") b.WriteString(Desc.Render("[esc] close")) return place(Box.Render(strings.TrimRight(b.String(), "\n")), width, height) }