1// Package shortcuts is the keyboard shortcuts view: a scrollable box2// over the grid listing every key by what it does, in two columns when3// the screen is wide enough. The rows come from the host, which builds4// them from the key bindings and the command registry. It knows the UI5// only through Host.6package shortcuts78import (9 "strconv"10 "strings"1112 tea "charm.land/bubbletea/v2"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/ui/overlay"16 "src.rest/cat/012/internal/ui/theme"17)1819// Host is what the shortcuts view needs of the UI.20type Host interface {21 Theme() *theme.Theme22 Size() (width, height int)23 // Close closes the open overlay.24 Close()25 // Shortcuts are the rows to list, in order.26 Shortcuts() []Row27}2829// Row is a line of the view: a group heading, or keys and what they do.30type Row struct {31 Heading string32 Keys []string // as shown, e.g. "Ctrl+G"33 Action string34}3536// View is the open shortcuts view.37type View struct {38 h Host39 top int40}4142// ID identifies the view's box in mouse events.43const ID = "shortcuts"4445// New returns the view scrolled to the top.46func New(h Host) *View { return &View{h: h} }4748// Top is the first line shown.49func (s *View) Top() int { return s.top }5051func (s *View) Indicator() string { return "HELP" }5253// Lines renders the rows, as one or two columns, and returns their54// width.55func (s *View) Lines() ([]string, int) {56 th := s.h.Theme()57 width, _ := s.h.Size()58 rows := s.h.Shortcuts()59 keyW, actW := 0, 060 for _, r := range rows {61 keyW = max(keyW, ansi.StringWidth(th.Chips(r.Keys)))62 actW = max(actW, ansi.StringWidth(r.Action))63 }64 // On narrow screens actions give way (truncated) so the keys fit.65 room := min(width-4, 160)66 if 1+keyW+2+actW > room {67 actW = max(room-3-keyW, 16)68 }69 colW := 1 + keyW + 2 + actW70 render := func(rows []Row) []string {71 var out []string72 for i, r := range rows {73 switch {74 case r.Heading != "" && i > 0:75 out = append(out, "", th.Title.Render(" "+r.Heading))76 case r.Heading != "":77 out = append(out, th.Title.Render(" "+r.Heading))78 default:79 out = append(out, " "+theme.PadRight(ansi.Truncate(r.Action, actW, "…"), actW)+" "+th.Chips(r.Keys))80 }81 }82 return out83 }84 if 2*colW+3 > room {85 return render(rows), min(colW, room)86 }87 // Two columns, split at the group boundary nearest the middle.88 split, best := 0, len(rows)89 for i, r := range rows {90 if d := abs(len(rows) - 2*i); r.Heading != "" && d < best {91 split, best = i, d92 }93 }94 left, right := render(rows[:split]), render(rows[split:])95 out := make([]string, max(len(left), len(right)))96 for i := range out {97 var l, r string98 if i < len(left) {99 l = left[i]100 }101 if i < len(right) {102 r = right[i]103 }104 out[i] = theme.PadRight(l, colW) + " " + r105 }106 return out, 2*colW + 3107}108109func abs(x int) int { return max(x, -x) }110111// visible is how many lines fit between the menu bar and the status line.112func (s *View) visible(total int) int {113 _, height := s.h.Size()114 return max(min(total, height-2-2-1), 1)115}116117func (s *View) Layout() []overlay.Box {118 th := s.h.Theme()119 width, height := s.h.Size()120 lines, w := s.Lines()121 inner := w + 1122 n := s.visible(len(lines) + 1)123 s.top = min(max(s.top, 0), max(len(lines)+1-n, 0))124 lines = append([]string{""}, lines...) // breathing room under the title125 rows := make([]string, n)126 for i := range rows {127 if j := s.top + i; j < len(lines) {128 rows[i] = theme.Cells(th.MenuBar, lines[j], inner)129 } else {130 rows[i] = strings.Repeat(" ", inner)131 }132 }133 footer := ""134 if n < len(lines) {135 footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))136 }137 b := th.Frame(inner, "Keyboard shortcuts", footer, rows)138 bw, bh := ansi.StringWidth(b[0]), len(b)139 return []overlay.Box{{ID: ID, X: (width - bw) / 2, Y: max(overlay.MenuLine+1, (height-1-bh)/2), Lines: b}}140}141142func (s *View) Key(k tea.KeyPressMsg) tea.Cmd {143 lines, _ := s.Lines()144 page := s.visible(len(lines) + 1)145 switch k.String() {146 case "up", "k":147 s.top--148 case "down", "j":149 s.top++150 case "pgup":151 s.top -= page152 case "pgdown", "space":153 s.top += page154 case "home":155 s.top = 0156 case "end":157 s.top = len(lines)158 case "esc", "enter", "q", "f1", "ctrl+/":159 s.h.Close()160 }161 s.top = min(max(s.top, 0), max(len(lines)+1-page, 0))162 return nil163}164165func (s *View) Mouse(e overlay.MouseEvent) tea.Cmd {166 switch {167 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:168 s.top = max(s.top-3, 0)169 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:170 s.top += 3 // layout clamps171 case e.Kind == overlay.MousePress && e.Box != ID:172 s.h.Close()173 }174 return nil175}176177func (s *View) Status() (string, string) {178 return "", s.h.Theme().KeyHints("Up/Down", "scroll", "Esc", "close")179}