git/orchard/main
tui/internal/ui/modal.go
329 lines9 KB last change raw · history
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)
}