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)
}