cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / menuoverlay.go
menuoverlay.go 10.9K
1package ui23import (4 "strconv"5 "strings"67 tea "charm.land/bubbletea/v2"8 "github.com/charmbracelet/x/ansi"910 "src.rest/cat/012/internal/ui/overlay"11 "src.rest/cat/012/internal/ui/theme"12)1314// menuHost is what an open menu acts on. The model implements it. Menus15// stay in package ui because their items are the command registry and16// the menu bar's definitions.17type menuHost interface {18 styles() *theme.Theme19 size() (width, height int)20 available(id string) bool21 shortcut(id string) string22 isChecked(id string) bool23 showBarMenu(i int)24 rightClick(x, y int)25 closeOverlay()26 // runFromOverlay closes the menu and runs a command through runCommand.27 runFromOverlay(id string) tea.Cmd28}2930// menuOverlay is an open dropdown from the menu bar, or a context menu,31// with any submenus opened from it.32type menuOverlay struct {33 m menuHost // the model, through what a menu needs of it34 bar int // index in barMenus of the open title; -1 for a context menu35 x, y int // where a context menu opened36 levels []*menuLevel37}3839// menuLevel is one open box: the dropdown or a submenu.40type menuLevel struct {41 items []menuItem42 sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering43 top int // first item shown when the box is too tall for the screen44}4546// showBarMenu opens dropdown i of barMenus.47func (m *Model) showBarMenu(i int) {48 menus := barMenus()49 if len(menus) == 0 {50 return51 }52 i = (i%len(menus) + len(menus)) % len(menus)53 m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{newLevel(m, visibleItems(m.applicable(menus[i].def.items)), true)}})54}5556// applicable drops the items whose commands are hidden in this session57// (command.hidden), in submenus too.58func (m *Model) applicable(items []menuItem) []menuItem {59 out := make([]menuItem, 0, len(items))60 for _, it := range items {61 if it.items != nil {62 it.items = m.applicable(it.items)63 }64 if c := commands[it.cmd]; c == nil || (c.hidden == nil || !c.hidden(m)) && m.out.offers(c.id) {65 out = append(out, it)66 }67 }68 return out69}7071// showContextMenu opens a menu of items with its corner at x, y.72func (m *Model) showContextMenu(items []menuItem, x, y int) {73 if items = visibleItems(m.applicable(items)); len(items) > 0 {74 m.openOverlay(&menuOverlay{m: m, bar: -1, x: x, y: y, levels: []*menuLevel{newLevel(m, items, true)}})75 }76}7778func newLevel(m menuHost, items []menuItem, highlight bool) *menuLevel {79 l := &menuLevel{items: items, sel: -1}80 if highlight {81 l.step(m, 1)82 }83 return l84}8586func (l *menuLevel) selectable(m menuHost, i int) bool {87 it := l.items[i]88 return !it.sep && (it.items != nil || m.available(it.cmd))89}9091// step moves the highlight to the next selectable item in direction d,92// wrapping around.93func (l *menuLevel) step(m menuHost, d int) {94 n, i := len(l.items), l.sel95 if i < 0 && d < 0 {96 i = n97 }98 for range n {99 i = ((i+d)%n + n) % n100 if l.selectable(m, i) {101 l.sel = i102 return103 }104 }105}106107func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }108109func (o *menuOverlay) Indicator() string { return "MENU" }110111// Key handles a key in an open menu: Up/Down highlight, Right opens a112// submenu or the next menu, Left closes a submenu or opens the previous113// menu, Enter runs, Esc closes one level, and a letter jumps to the items114// starting with it, running the item if it's the only one.115func (o *menuOverlay) Key(k tea.KeyPressMsg) tea.Cmd {116 m := o.m117 l := o.top()118 key := k.String()119 if i := barMenuFor(key); i >= 0 {120 m.showBarMenu(i)121 return nil122 }123 switch key {124 case "up", "shift+tab":125 l.step(m, -1)126 case "down", "tab":127 l.step(m, 1)128 case "home":129 l.sel = -1130 l.step(m, 1)131 case "end":132 l.sel = -1133 l.step(m, -1)134 case "right":135 if l.sel >= 0 && l.items[l.sel].items != nil {136 return o.choose(m)137 }138 if l.sel < 0 {139 l.step(m, 1)140 } else if o.bar >= 0 {141 m.showBarMenu(o.bar + 1)142 }143 case "left":144 if len(o.levels) > 1 {145 o.levels = o.levels[:len(o.levels)-1]146 } else if o.bar >= 0 {147 m.showBarMenu(o.bar - 1)148 }149 case "enter", "space":150 if l.sel < 0 {151 l.step(m, 1)152 return nil153 }154 return o.choose(m)155 case "esc":156 if len(o.levels) > 1 {157 o.levels = o.levels[:len(o.levels)-1]158 } else {159 m.closeOverlay()160 }161 case "f10":162 m.closeOverlay()163 default:164 return o.jump(m, typed(k))165 }166 return nil167}168169// jump highlights the next item starting with letter, running it if it's170// the only one.171func (o *menuOverlay) jump(m menuHost, letter string) tea.Cmd {172 l := o.top()173 if letter == "" {174 return nil175 }176 var matches []int177 for i, it := range l.items {178 if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {179 matches = append(matches, i)180 }181 }182 switch len(matches) {183 case 0:184 return nil185 case 1:186 l.sel = matches[0]187 return o.choose(m)188 }189 next := matches[0]190 for _, i := range matches {191 if i > l.sel {192 next = i193 break194 }195 }196 l.sel = next197 return nil198}199200// choose opens the highlighted submenu or runs the highlighted command.201func (o *menuOverlay) choose(m menuHost) tea.Cmd {202 l := o.top()203 if l.sel < 0 || !l.selectable(m, l.sel) {204 return nil205 }206 it := l.items[l.sel]207 if it.items != nil {208 o.levels = append(o.levels, newLevel(m, it.items, true))209 return nil210 }211 return m.runFromOverlay(it.cmd)212}213214func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }215216// Mouse handles hovering and clicking items, switching menus by hovering217// or clicking the menu bar, and closing the menu on a click elsewhere.218func (o *menuOverlay) Mouse(e overlay.MouseEvent) tea.Cmd {219 m := o.m220 for lv, l := range o.levels {221 if e.Box == menuBoxID(lv) {222 return o.levelMouse(m, e, lv, l)223 }224 }225 if e.Y == menuLine && e.Box == "" {226 if t := barMenuAt(e.X); t >= 0 {227 o.barMouse(m, e, t)228 return nil229 }230 }231 if e.Kind == overlay.MousePress {232 m.closeOverlay()233 if e.Button == tea.MouseRight {234 m.rightClick(e.X, e.Y) // right-clicking elsewhere opens a menu there235 }236 }237 return nil238}239240// levelMouse handles the mouse over open box lv: hovering highlights an241// item and opens its submenu, clicking chooses it, the wheel moves.242func (o *menuOverlay) levelMouse(m menuHost, e overlay.MouseEvent, lv int, l *menuLevel) tea.Cmd {243 i := l.top + e.Row - 1244 if e.Kind == overlay.MouseWheel {245 o.levels = o.levels[:lv+1]246 l.wheel(m, e.Button)247 return nil248 }249 if e.Row < 1 || i >= len(l.items) || !l.selectable(m, i) {250 return nil251 }252 switch e.Kind {253 case overlay.MouseMotion:254 if i != l.sel || len(o.levels) == lv+1 {255 o.levels, l.sel = o.levels[:lv+1], i256 if sub := l.items[i].items; sub != nil {257 o.levels = append(o.levels, newLevel(m, sub, false))258 }259 }260 case overlay.MousePress:261 o.levels, l.sel = o.levels[:lv+1], i262 return o.choose(m)263 }264 return nil265}266267// barMouse handles the mouse over menu bar title t: clicking the open268// title closes its menu, and clicking or hovering another opens that one.269func (o *menuOverlay) barMouse(m menuHost, e overlay.MouseEvent, t int) {270 switch {271 case e.Kind == overlay.MousePress && t == o.bar:272 m.closeOverlay()273 case e.Kind == overlay.MousePress, e.Kind == overlay.MouseMotion && o.bar >= 0 && t != o.bar:274 m.showBarMenu(t)275 }276}277278// wheel moves the highlight, which scrolls a menu too tall for the screen.279func (l *menuLevel) wheel(m menuHost, b tea.MouseButton) {280 switch b {281 case tea.MouseWheelUp:282 l.step(m, -1)283 case tea.MouseWheelDown:284 l.step(m, 1)285 }286}287288func (o *menuOverlay) Status() (string, string) {289 m := o.m290 keys := m.styles().KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")291 if o.bar >= 0 {292 keys = m.styles().KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")293 }294 l := o.top()295 if l.sel < 0 {296 return "", keys297 }298 it := l.items[l.sel]299 if it.items != nil {300 names := make([]string, 0, len(it.items))301 for _, sub := range it.items {302 if !sub.sep {303 names = append(names, sub.label())304 }305 }306 return strings.Join(names, ", "), keys307 }308 return commands[it.cmd].desc, keys309}310311// Layout places the dropdown under its title (or a context menu at the312// mouse) and each submenu beside the item that opened it, flipping left or313// up where the screen runs out. The status line stays visible.314func (o *menuOverlay) Layout() []overlay.Box {315 m := o.m316 width, height := m.size()317 boxes := make([]overlay.Box, 0, len(o.levels))318 maxRows := max(height-4, 3)319 for i, l := range o.levels {320 rows := min(len(l.items), maxRows)321 if l.sel >= 0 {322 l.top = clamp(l.top, l.sel-rows+1, l.sel)323 }324 l.top = clamp(l.top, 0, len(l.items)-rows)325 lines := l.lines(m, rows)326 w, h := ansi.StringWidth(lines[0]), len(lines)327 var x, y int328 switch {329 case i > 0:330 p := boxes[i-1]331 x, y = p.X+p.Width(), p.Y+o.levels[i-1].sel-o.levels[i-1].top332 // Open to the right; else to the left; and if neither fits,333 // against the screen's right edge, where it covers the parent's334 // shortcuts rather than its labels.335 if x+w > width {336 x = p.X - w337 if x < 0 {338 x = width - w339 }340 }341 case o.bar >= 0:342 x, y = barMenus()[o.bar].x, menuLine+1343 default:344 x, y = o.x, o.y345 if y+h > height-1 {346 y = o.y - h347 }348 }349 x = clamp(x, 0, width-w)350 y = clamp(y, 0, height-1-h)351 boxes = append(boxes, overlay.Box{ID: menuBoxID(i), X: x, Y: y, Lines: lines})352 }353 return boxes354}355356// lines draws the box: each item with its shortcut right-aligned, a357// › for submenus, separators between groups, unavailable items dimmed and358// arrows in the border when the items don't all fit.359func (l *menuLevel) lines(m menuHost, rows int) []string {360 lw, kw := 0, 0361 for _, it := range l.items {362 lw = max(lw, ansi.StringWidth(it.label()))363 kw = max(kw, ansi.StringWidth(it.key(m, false)))364 }365 inner := max(lw+kw+5, 18)366 out := make([]string, 0, rows)367 for i := l.top; i < l.top+rows; i++ {368 it := l.items[i]369 if it.sep {370 out = append(out, theme.SepRow)371 continue372 }373 style := m.styles().MenuBar374 switch {375 case i == l.sel:376 style = m.styles().MenuSelected377 case !l.selectable(m, i):378 style = m.styles().Disabled379 }380 k := it.key(m, !l.selectable(m, i))381 if it.items != nil || i == l.sel {382 // The highlight runs unbroken across the row.383 k = style.Render(ansi.Strip(k))384 }385 out = append(out, theme.Cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))386 }387 var up, down string388 if l.top > 0 {389 up = "▲"390 }391 if l.top+rows < len(l.items) {392 down = "▼"393 }394 return m.styles().Frame(inner, up, down, out)395}396397// isChecked reports whether the command is a setting that is on.398func (m *Model) isChecked(id string) bool {399 c := commands[id]400 return c != nil && c.checked != nil && c.checked(m)401}402403// key is what an item shows on the right: its shortcut as a key chip404// (plain when the item is unavailable), › for a submenu, or ✓ for a405// setting that is on.406func (it menuItem) key(m menuHost, disabled bool) string {407 k := m.shortcut(it.cmd)408 switch {409 case it.items != nil:410 return "›"411 case k == "" && m.isChecked(it.cmd):412 return "✓"413 case k == "":414 return ""415 case disabled:416 return m.styles().Disabled.Render(" " + k + " ")417 }418 return m.styles().Chip(k)419}