menubar.go keeps the menu bar's definition and titles; the open dropdown or context menu, with its submenus, moves to menuoverlay.go. Its mouse handling (cognitive complexity 30) splits into the boxes and the menu bar titles, and drawing a box and an item's key move onto menuLevel and menuItem.
- commit
- 139f5c2acbdd1568ba826575e0129790a26a94fb
- parent
- a1b2028215cf8444df652b45bd2e162a9a4fad1b
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 363 | ----------------------------- |
| A | internal/ | 381 | ++++++++++++++++++++++++++++++ |
@@ -1,13 +1,9 @@
11 package ui22 33 import (4- "strconv"54 "strings"65 76 tea "charm.land/bubbletea/v2"8- "github.com/charmbracelet/x/ansi"9-10- "github.com/FelineStateMachine/012/internal/ui/theme"117 )128 139 // The menu bar follows Google Sheets: File Edit View Insert Format Data@@ -223,362 +219,3 @@ func init() {
223219 }})224220 keymap["f10"] = "menu"225221 }226-227-// menuOverlay is an open dropdown from the menu bar, or a context menu,228-// with any submenus opened from it.229-type menuOverlay struct {230- bar int // index in barMenus of the open title; -1 for a context menu231- x, y int // where a context menu opened232- levels []*menuLevel233-}234-235-// menuLevel is one open box: the dropdown or a submenu.236-type menuLevel struct {237- items []menuItem238- sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering239- top int // first item shown when the box is too tall for the screen240-}241-242-// showBarMenu opens dropdown i of barMenus.243-func (m *Model) showBarMenu(i int) {244- menus := barMenus()245- if len(menus) == 0 {246- return247- }248- i = (i%len(menus) + len(menus)) % len(menus)249- m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})250-}251-252-// showContextMenu opens a menu of items with its corner at x, y.253-func (m *Model) showContextMenu(items []menuItem, x, y int) {254- if items = visibleItems(items); len(items) > 0 {255- m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})256- }257-}258-259-func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {260- l := &menuLevel{items: items, sel: -1}261- if highlight {262- l.step(m, 1)263- }264- return l265-}266-267-func (l *menuLevel) selectable(m *Model, i int) bool {268- it := l.items[i]269- return !it.sep && (it.items != nil || commands[it.cmd].available(m))270-}271-272-// step moves the highlight to the next selectable item in direction d,273-// wrapping around.274-func (l *menuLevel) step(m *Model, d int) {275- n, i := len(l.items), l.sel276- if i < 0 && d < 0 {277- i = n278- }279- for range n {280- i = ((i+d)%n + n) % n281- if l.selectable(m, i) {282- l.sel = i283- return284- }285- }286-}287-288-func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }289-290-func (o *menuOverlay) indicator() string { return "MENU" }291-292-// key handles a key in an open menu: Up/Down highlight, Right opens a293-// submenu or the next menu, Left closes a submenu or opens the previous294-// menu, Enter runs, Esc closes one level, and a letter jumps to the items295-// starting with it, running the item if it's the only one.296-func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {297- l := o.top()298- key := k.String()299- if i := barMenuFor(key); i >= 0 {300- m.showBarMenu(i)301- return nil302- }303- switch key {304- case "up", "shift+tab":305- l.step(m, -1)306- case "down", "tab":307- l.step(m, 1)308- case "home":309- l.sel = -1310- l.step(m, 1)311- case "end":312- l.sel = -1313- l.step(m, -1)314- case "right":315- if l.sel >= 0 && l.items[l.sel].items != nil {316- return o.choose(m)317- }318- if l.sel < 0 {319- l.step(m, 1)320- } else if o.bar >= 0 {321- m.showBarMenu(o.bar + 1)322- }323- case "left":324- if len(o.levels) > 1 {325- o.levels = o.levels[:len(o.levels)-1]326- } else if o.bar >= 0 {327- m.showBarMenu(o.bar - 1)328- }329- case "enter", "space":330- if l.sel < 0 {331- l.step(m, 1)332- return nil333- }334- return o.choose(m)335- case "esc":336- if len(o.levels) > 1 {337- o.levels = o.levels[:len(o.levels)-1]338- } else {339- m.closeOverlay()340- }341- case "f10":342- m.closeOverlay()343- default:344- return o.jump(m, typed(k))345- }346- return nil347-}348-349-// jump highlights the next item starting with letter, running it if it's350-// the only one.351-func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {352- l := o.top()353- if letter == "" {354- return nil355- }356- var matches []int357- for i, it := range l.items {358- if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {359- matches = append(matches, i)360- }361- }362- switch len(matches) {363- case 0:364- return nil365- case 1:366- l.sel = matches[0]367- return o.choose(m)368- }369- next := matches[0]370- for _, i := range matches {371- if i > l.sel {372- next = i373- break374- }375- }376- l.sel = next377- return nil378-}379-380-// choose opens the highlighted submenu or runs the highlighted command.381-func (o *menuOverlay) choose(m *Model) tea.Cmd {382- l := o.top()383- if l.sel < 0 || !l.selectable(m, l.sel) {384- return nil385- }386- it := l.items[l.sel]387- if it.items != nil {388- o.levels = append(o.levels, m.newLevel(it.items, true))389- return nil390- }391- return m.runFromOverlay(it.cmd)392-}393-394-func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }395-396-// mouse handles hovering and clicking items, switching menus by hovering397-// or clicking the menu bar, and closing the menu on a click elsewhere.398-func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {399- for lv, l := range o.levels {400- if e.box != menuBoxID(lv) {401- continue402- }403- i := l.top + e.row - 1404- if e.kind == mouseWheel {405- o.levels = o.levels[:lv+1]406- l.wheel(m, e.button)407- return nil408- }409- if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {410- return nil411- }412- switch e.kind {413- case mouseMotion:414- if i != l.sel || len(o.levels) == lv+1 {415- o.levels, l.sel = o.levels[:lv+1], i416- if sub := l.items[i].items; sub != nil {417- o.levels = append(o.levels, m.newLevel(sub, false))418- }419- }420- case mousePress:421- o.levels, l.sel = o.levels[:lv+1], i422- return o.choose(m)423- }424- return nil425- }426- if e.y == menuLine && e.box == "" {427- if t := barMenuAt(e.x); t >= 0 {428- switch {429- case e.kind == mousePress && t == o.bar:430- m.closeOverlay()431- case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:432- m.showBarMenu(t)433- }434- return nil435- }436- }437- if e.kind == mousePress {438- m.closeOverlay()439- if e.button == tea.MouseRight {440- m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there441- }442- }443- return nil444-}445-446-// wheel moves the highlight, which scrolls a menu too tall for the screen.447-func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {448- switch b {449- case tea.MouseWheelUp:450- l.step(m, -1)451- case tea.MouseWheelDown:452- l.step(m, 1)453- }454-}455-456-func (o *menuOverlay) status(m *Model) (string, string) {457- keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")458- if o.bar >= 0 {459- keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")460- }461- l := o.top()462- if l.sel < 0 {463- return "", keys464- }465- it := l.items[l.sel]466- if it.items != nil {467- names := make([]string, 0, len(it.items))468- for _, sub := range it.items {469- if !sub.sep {470- names = append(names, sub.label())471- }472- }473- return strings.Join(names, ", "), keys474- }475- return commands[it.cmd].desc, keys476-}477-478-// layout places the dropdown under its title (or a context menu at the479-// mouse) and each submenu beside the item that opened it, flipping left or480-// up where the screen runs out. The status line stays visible.481-func (o *menuOverlay) layout(m *Model) []box {482- boxes := make([]box, 0, len(o.levels))483- maxRows := max(m.height-4, 3)484- for i, l := range o.levels {485- rows := min(len(l.items), maxRows)486- if l.sel >= 0 {487- l.top = clamp(l.top, l.sel-rows+1, l.sel)488- }489- l.top = clamp(l.top, 0, len(l.items)-rows)490- lines := m.dropdown(l, rows)491- w, h := ansi.StringWidth(lines[0]), len(lines)492- var x, y int493- switch {494- case i > 0:495- p := boxes[i-1]496- x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top497- // Open to the right; else to the left; and if neither fits,498- // against the screen's right edge, where it covers the parent's499- // shortcuts rather than its labels.500- if x+w > m.width {501- x = p.x - w502- if x < 0 {503- x = m.width - w504- }505- }506- case o.bar >= 0:507- x, y = barMenus()[o.bar].x, menuLine+1508- default:509- x, y = o.x, o.y510- if y+h > m.height-1 {511- y = o.y - h512- }513- }514- x = clamp(x, 0, m.width-w)515- y = clamp(y, 0, m.height-1-h)516- boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})517- }518- return boxes519-}520-521-// dropdown draws a menu box: each item with its shortcut right-aligned, a522-// › for submenus, separators between groups, unavailable items dimmed and523-// arrows in the border when the items don't all fit.524-func (m *Model) dropdown(l *menuLevel, rows int) []string {525- lw, kw := 0, 0526- for _, it := range l.items {527- lw = max(lw, ansi.StringWidth(it.label()))528- kw = max(kw, ansi.StringWidth(m.itemKey(it, false)))529- }530- inner := max(lw+kw+5, 18)531- out := make([]string, 0, rows)532- for i := l.top; i < l.top+rows; i++ {533- it := l.items[i]534- if it.sep {535- out = append(out, theme.SepRow)536- continue537- }538- style := m.th.MenuBar539- switch {540- case i == l.sel:541- style = m.th.MenuSelected542- case !l.selectable(m, i):543- style = m.th.Disabled544- }545- k := m.itemKey(it, !l.selectable(m, i))546- if it.items != nil || i == l.sel {547- // The highlight runs unbroken across the row.548- k = style.Render(ansi.Strip(k))549- }550- out = append(out, theme.Cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))551- }552- var up, down string553- if l.top > 0 {554- up = "▲"555- }556- if l.top+rows < len(l.items) {557- down = "▼"558- }559- return m.th.Frame(inner, up, down, out)560-}561-562-// isChecked reports whether the command is a setting that is on.563-func (m *Model) isChecked(id string) bool {564- c := commands[id]565- return c != nil && c.checked != nil && c.checked(m)566-}567-568-// itemKey is what an item shows on the right: its shortcut as a key chip569-// (plain when the item is unavailable), › for a submenu, or ✓ for a570-// setting that is on.571-func (m *Model) itemKey(it menuItem, disabled bool) string {572- k := shortcut(it.cmd)573- switch {574- case it.items != nil:575- return "›"576- case k == "" && m.isChecked(it.cmd):577- return "✓"578- case k == "":579- return ""580- case disabled:581- return m.th.Disabled.Render(" " + k + " ")582- }583- return m.th.Chip(k)584-}@@ -0,0 +1,381 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// menuOverlay is an open dropdown from the menu bar, or a context menu,14+// with any submenus opened from it.15+type menuOverlay struct {16+ bar int // index in barMenus of the open title; -1 for a context menu17+ x, y int // where a context menu opened18+ levels []*menuLevel19+}20+21+// menuLevel is one open box: the dropdown or a submenu.22+type menuLevel struct {23+ items []menuItem24+ sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering25+ top int // first item shown when the box is too tall for the screen26+}27+28+// showBarMenu opens dropdown i of barMenus.29+func (m *Model) showBarMenu(i int) {30+ menus := barMenus()31+ if len(menus) == 0 {32+ return33+ }34+ i = (i%len(menus) + len(menus)) % len(menus)35+ m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})36+}37+38+// showContextMenu opens a menu of items with its corner at x, y.39+func (m *Model) showContextMenu(items []menuItem, x, y int) {40+ if items = visibleItems(items); len(items) > 0 {41+ m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})42+ }43+}44+45+func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {46+ l := &menuLevel{items: items, sel: -1}47+ if highlight {48+ l.step(m, 1)49+ }50+ return l51+}52+53+func (l *menuLevel) selectable(m *Model, i int) bool {54+ it := l.items[i]55+ return !it.sep && (it.items != nil || commands[it.cmd].available(m))56+}57+58+// step moves the highlight to the next selectable item in direction d,59+// wrapping around.60+func (l *menuLevel) step(m *Model, d int) {61+ n, i := len(l.items), l.sel62+ if i < 0 && d < 0 {63+ i = n64+ }65+ for range n {66+ i = ((i+d)%n + n) % n67+ if l.selectable(m, i) {68+ l.sel = i69+ return70+ }71+ }72+}73+74+func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }75+76+func (o *menuOverlay) indicator() string { return "MENU" }77+78+// key handles a key in an open menu: Up/Down highlight, Right opens a79+// submenu or the next menu, Left closes a submenu or opens the previous80+// menu, Enter runs, Esc closes one level, and a letter jumps to the items81+// starting with it, running the item if it's the only one.82+func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {83+ l := o.top()84+ key := k.String()85+ if i := barMenuFor(key); i >= 0 {86+ m.showBarMenu(i)87+ return nil88+ }89+ switch key {90+ case "up", "shift+tab":91+ l.step(m, -1)92+ case "down", "tab":93+ l.step(m, 1)94+ case "home":95+ l.sel = -196+ l.step(m, 1)97+ case "end":98+ l.sel = -199+ l.step(m, -1)100+ case "right":101+ if l.sel >= 0 && l.items[l.sel].items != nil {102+ return o.choose(m)103+ }104+ if l.sel < 0 {105+ l.step(m, 1)106+ } else if o.bar >= 0 {107+ m.showBarMenu(o.bar + 1)108+ }109+ case "left":110+ if len(o.levels) > 1 {111+ o.levels = o.levels[:len(o.levels)-1]112+ } else if o.bar >= 0 {113+ m.showBarMenu(o.bar - 1)114+ }115+ case "enter", "space":116+ if l.sel < 0 {117+ l.step(m, 1)118+ return nil119+ }120+ return o.choose(m)121+ case "esc":122+ if len(o.levels) > 1 {123+ o.levels = o.levels[:len(o.levels)-1]124+ } else {125+ m.closeOverlay()126+ }127+ case "f10":128+ m.closeOverlay()129+ default:130+ return o.jump(m, typed(k))131+ }132+ return nil133+}134+135+// jump highlights the next item starting with letter, running it if it's136+// the only one.137+func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {138+ l := o.top()139+ if letter == "" {140+ return nil141+ }142+ var matches []int143+ for i, it := range l.items {144+ if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {145+ matches = append(matches, i)146+ }147+ }148+ switch len(matches) {149+ case 0:150+ return nil151+ case 1:152+ l.sel = matches[0]153+ return o.choose(m)154+ }155+ next := matches[0]156+ for _, i := range matches {157+ if i > l.sel {158+ next = i159+ break160+ }161+ }162+ l.sel = next163+ return nil164+}165+166+// choose opens the highlighted submenu or runs the highlighted command.167+func (o *menuOverlay) choose(m *Model) tea.Cmd {168+ l := o.top()169+ if l.sel < 0 || !l.selectable(m, l.sel) {170+ return nil171+ }172+ it := l.items[l.sel]173+ if it.items != nil {174+ o.levels = append(o.levels, m.newLevel(it.items, true))175+ return nil176+ }177+ return m.runFromOverlay(it.cmd)178+}179+180+func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }181+182+// mouse handles hovering and clicking items, switching menus by hovering183+// or clicking the menu bar, and closing the menu on a click elsewhere.184+func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {185+ for lv, l := range o.levels {186+ if e.box == menuBoxID(lv) {187+ return o.levelMouse(m, e, lv, l)188+ }189+ }190+ if e.y == menuLine && e.box == "" {191+ if t := barMenuAt(e.x); t >= 0 {192+ o.barMouse(m, e, t)193+ return nil194+ }195+ }196+ if e.kind == mousePress {197+ m.closeOverlay()198+ if e.button == tea.MouseRight {199+ m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there200+ }201+ }202+ return nil203+}204+205+// levelMouse handles the mouse over open box lv: hovering highlights an206+// item and opens its submenu, clicking chooses it, the wheel moves.207+func (o *menuOverlay) levelMouse(m *Model, e mouseEvent, lv int, l *menuLevel) tea.Cmd {208+ i := l.top + e.row - 1209+ if e.kind == mouseWheel {210+ o.levels = o.levels[:lv+1]211+ l.wheel(m, e.button)212+ return nil213+ }214+ if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {215+ return nil216+ }217+ switch e.kind {218+ case mouseMotion:219+ if i != l.sel || len(o.levels) == lv+1 {220+ o.levels, l.sel = o.levels[:lv+1], i221+ if sub := l.items[i].items; sub != nil {222+ o.levels = append(o.levels, m.newLevel(sub, false))223+ }224+ }225+ case mousePress:226+ o.levels, l.sel = o.levels[:lv+1], i227+ return o.choose(m)228+ }229+ return nil230+}231+232+// barMouse handles the mouse over menu bar title t: clicking the open233+// title closes its menu, and clicking or hovering another opens that one.234+func (o *menuOverlay) barMouse(m *Model, e mouseEvent, t int) {235+ switch {236+ case e.kind == mousePress && t == o.bar:237+ m.closeOverlay()238+ case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:239+ m.showBarMenu(t)240+ }241+}242+243+// wheel moves the highlight, which scrolls a menu too tall for the screen.244+func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {245+ switch b {246+ case tea.MouseWheelUp:247+ l.step(m, -1)248+ case tea.MouseWheelDown:249+ l.step(m, 1)250+ }251+}252+253+func (o *menuOverlay) status(m *Model) (string, string) {254+ keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")255+ if o.bar >= 0 {256+ keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")257+ }258+ l := o.top()259+ if l.sel < 0 {260+ return "", keys261+ }262+ it := l.items[l.sel]263+ if it.items != nil {264+ names := make([]string, 0, len(it.items))265+ for _, sub := range it.items {266+ if !sub.sep {267+ names = append(names, sub.label())268+ }269+ }270+ return strings.Join(names, ", "), keys271+ }272+ return commands[it.cmd].desc, keys273+}274+275+// layout places the dropdown under its title (or a context menu at the276+// mouse) and each submenu beside the item that opened it, flipping left or277+// up where the screen runs out. The status line stays visible.278+func (o *menuOverlay) layout(m *Model) []box {279+ boxes := make([]box, 0, len(o.levels))280+ maxRows := max(m.height-4, 3)281+ for i, l := range o.levels {282+ rows := min(len(l.items), maxRows)283+ if l.sel >= 0 {284+ l.top = clamp(l.top, l.sel-rows+1, l.sel)285+ }286+ l.top = clamp(l.top, 0, len(l.items)-rows)287+ lines := l.lines(m, rows)288+ w, h := ansi.StringWidth(lines[0]), len(lines)289+ var x, y int290+ switch {291+ case i > 0:292+ p := boxes[i-1]293+ x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top294+ // Open to the right; else to the left; and if neither fits,295+ // against the screen's right edge, where it covers the parent's296+ // shortcuts rather than its labels.297+ if x+w > m.width {298+ x = p.x - w299+ if x < 0 {300+ x = m.width - w301+ }302+ }303+ case o.bar >= 0:304+ x, y = barMenus()[o.bar].x, menuLine+1305+ default:306+ x, y = o.x, o.y307+ if y+h > m.height-1 {308+ y = o.y - h309+ }310+ }311+ x = clamp(x, 0, m.width-w)312+ y = clamp(y, 0, m.height-1-h)313+ boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})314+ }315+ return boxes316+}317+318+// lines draws the box: each item with its shortcut right-aligned, a319+// › for submenus, separators between groups, unavailable items dimmed and320+// arrows in the border when the items don't all fit.321+func (l *menuLevel) lines(m *Model, rows int) []string {322+ lw, kw := 0, 0323+ for _, it := range l.items {324+ lw = max(lw, ansi.StringWidth(it.label()))325+ kw = max(kw, ansi.StringWidth(it.key(m, false)))326+ }327+ inner := max(lw+kw+5, 18)328+ out := make([]string, 0, rows)329+ for i := l.top; i < l.top+rows; i++ {330+ it := l.items[i]331+ if it.sep {332+ out = append(out, theme.SepRow)333+ continue334+ }335+ style := m.th.MenuBar336+ switch {337+ case i == l.sel:338+ style = m.th.MenuSelected339+ case !l.selectable(m, i):340+ style = m.th.Disabled341+ }342+ k := it.key(m, !l.selectable(m, i))343+ if it.items != nil || i == l.sel {344+ // The highlight runs unbroken across the row.345+ k = style.Render(ansi.Strip(k))346+ }347+ out = append(out, theme.Cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))348+ }349+ var up, down string350+ if l.top > 0 {351+ up = "▲"352+ }353+ if l.top+rows < len(l.items) {354+ down = "▼"355+ }356+ return m.th.Frame(inner, up, down, out)357+}358+359+// isChecked reports whether the command is a setting that is on.360+func (m *Model) isChecked(id string) bool {361+ c := commands[id]362+ return c != nil && c.checked != nil && c.checked(m)363+}364+365+// key is what an item shows on the right: its shortcut as a key chip366+// (plain when the item is unavailable), › for a submenu, or ✓ for a367+// setting that is on.368+func (it menuItem) key(m *Model, disabled bool) string {369+ k := shortcut(it.cmd)370+ switch {371+ case it.items != nil:372+ return "›"373+ case k == "" && m.isChecked(it.cmd):374+ return "✓"375+ case k == "":376+ return ""377+ case disabled:378+ return m.th.Disabled.Render(" " + k + " ")379+ }380+ return m.th.Chip(k)381+}