| 🛟 Updated. 28d5985 k33g 16h ago | 1 | // The menus the project's tools file asks for: the editor's own toolchain menu, |
| 2 | // and any others a tool names for itself. |
| 3 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 7 | "os" |
| 8 | "time" |
| 9 | "unicode" |
| 10 | |
| 11 | "codeberg.org/turbo-editors/turbo-core/tools" |
| 12 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 13 | ) |
| 14 | |
| 15 | // allMenus returns the whole bar: the fixed menus, then the ones the project's |
| 16 | // tools file asks for, then Help. |
| 17 | // |
| 18 | // Help stays last because that is where Turbo C put it and where a reader |
| 19 | // looks for it. The project's own menus land between it and Go, which is where |
| 20 | // a menu grown out of the file belongs — after everything the editor itself |
| 21 | // offers. |
| 22 | func (a *App) allMenus() []*ui.Menu { |
| 23 | fixed := []*ui.Menu{ |
| 24 | a.fileMenu(), |
| 25 | a.editMenu(), |
| 26 | a.searchMenu(), |
| 27 | a.runMenu(), |
| 28 | a.codeMenu(), |
| 29 | a.optionsMenu(), |
| 30 | a.windowMenu(), |
| 31 | a.snippetsMenu(), |
| 32 | a.agentMenu(), |
| 33 | a.toolchainMenu(), |
| 34 | } |
| 35 | help := a.helpMenu() |
| 36 | |
| 37 | menus := make([]*ui.Menu, 0, len(fixed)+1) |
| 38 | menus = append(menus, fixed...) |
| 39 | menus = append(menus, a.toolMenus(takenHotKeys(append(fixed, help)))...) |
| 40 | return append(menus, help) |
| 41 | } |
| 42 | |
| 43 | // toolMenus returns one menu per name the tools file uses, in the order the |
| 44 | // names first appear in it, skipping the editor's own because it is already on |
| 45 | // the bar. |
| 46 | // |
| 47 | // A file that cannot be read gives no menus at all. The toolchain menu shows |
| 48 | // the error instead: a broken file should say so once, not once per menu it was |
| 49 | // about to create. |
| 50 | func (a *App) toolMenus(taken map[rune]bool) []*ui.Menu { |
| 51 | list, err := a.loadTools() |
| 52 | if err != nil { |
| 53 | return nil |
| 54 | } |
| 55 | |
| 56 | var menus []*ui.Menu |
| 57 | for _, name := range list.MenuNames() { |
| 58 | if name == a.toolsMenuName() { |
| 59 | continue |
| 60 | } |
| 61 | menus = append(menus, a.toolMenu(name, hotKeyLabel(name, taken))) |
| 62 | } |
| 63 | return menus |
| 64 | } |
| 65 | |
| 66 | // toolMenu returns a menu holding the tools that named it. |
| 67 | // |
| 68 | // Like the toolchain menu, its items come from OnOpen: they are read from a |
| 69 | // file that changes while the editor runs. |
| 70 | func (a *App) toolMenu(name, label string) *ui.Menu { |
| 71 | menu := &ui.Menu{Label: label} |
| 72 | menu.OnOpen = func() { menu.Items = a.commandItems(name) } |
| 73 | return menu |
| 74 | } |
| 75 | |
| 76 | // takenHotKeys returns the hot keys the given menus already claim. |
| 77 | func takenHotKeys(menus []*ui.Menu) map[rune]bool { |
| 78 | taken := make(map[rune]bool, len(menus)) |
| 79 | for _, menu := range menus { |
| 80 | if _, key, index := ui.SplitHotKey(menu.Label); index >= 0 { |
| 81 | taken[unicode.ToLower(key)] = true |
| 82 | } |
| 83 | } |
| 84 | return taken |
| 85 | } |
| 86 | |
| 87 | // hotKeyLabel marks a menu name's first letter that no other menu has claimed, |
| 88 | // and reserves it. |
| 89 | // |
| 90 | // The bar answers the *first* menu whose hot key matches, so two menus sharing |
| 91 | // one silently make the second unreachable. A name written with the marker |
| 92 | // already in it — `menu = "~T~ools"` — keeps its own letter when it is free; |
| 93 | // when it is not, and when every letter of the name is spoken for, the menu |
| 94 | // gets no hot key at all rather than one that opens something else. F10 and |
| 95 | // the mouse still reach it. |
| 96 | // |
| 97 | // taken := map[rune]bool{'f': true} |
| 98 | // hotKeyLabel("Format", taken) // "F~o~rmat" |
| 99 | func hotKeyLabel(name string, taken map[rune]bool) string { |
| 100 | if plain, key, index := ui.SplitHotKey(name); index >= 0 { |
| 101 | if lower := unicode.ToLower(key); !taken[lower] { |
| 102 | taken[lower] = true |
| 103 | return name |
| 104 | } |
| 105 | name = plain // their letter is spoken for; find another |
| 106 | } |
| 107 | |
| 108 | runes := []rune(name) |
| 109 | for i, r := range runes { |
| 110 | lower := unicode.ToLower(r) |
| 111 | if !unicode.IsLetter(r) || taken[lower] { |
| 112 | continue |
| 113 | } |
| 114 | taken[lower] = true |
| 115 | marker := string(ui.HotKeyMarker) |
| 116 | return string(runes[:i]) + marker + string(r) + marker + string(runes[i+1:]) |
| 117 | } |
| 118 | return name |
| 119 | } |
| 120 | |
| 121 | // fileStamp is what a file looked like at a moment: enough to tell that it has |
| 122 | // changed without reading it. |
| 123 | type fileStamp struct { |
| 124 | exists bool |
| 125 | size int64 |
| 126 | modTime time.Time |
| 127 | } |
| 128 | |
| 129 | // stampOf describes a file. A file that is not there stamps as the zero value, |
| 130 | // so appearing and disappearing both register as changes. |
| 131 | func stampOf(path string) fileStamp { |
| 132 | info, err := os.Stat(path) |
| 133 | if err != nil { |
| 134 | return fileStamp{} |
| 135 | } |
| 136 | return fileStamp{exists: true, size: info.Size(), modTime: info.ModTime()} |
| 137 | } |
| 138 | |
| 139 | // toolsFileStamp describes the project's tools file. |
| 140 | func (a *App) toolsFileStamp() fileStamp { |
| 141 | working, err := os.Getwd() |
| 142 | if err != nil { |
| 143 | return fileStamp{} |
| 144 | } |
| 145 | return stampOf(tools.Path(a.profile, working)) |
| 146 | } |
| 147 | |
| 148 | // refreshToolMenus puts the bar back in step with the tools file. |
| 149 | // |
| 150 | // Menu.OnOpen refills a menu's items, but the *set* of menus comes from the |
| 151 | // file as well: adding `menu = "Docker"` to it should put a Docker menu on the |
| 152 | // bar without restarting the editor. Rebuilding is guarded by the file's size |
| 153 | // and modification time, so the usual case — nothing changed — costs one stat |
| 154 | // and no parsing. |
| 155 | // |
| 156 | // It runs at the top of the event loop, with the rest of the state-driven |
| 157 | // work, because rebuilding the bar from a goroutine would race the draw. |
| 158 | func (a *App) refreshToolMenus() { |
| 159 | stamp := a.toolsFileStamp() |
| 160 | if stamp == a.toolsStamp { |
| 161 | return |
| 162 | } |
| 163 | a.toolsStamp = stamp |
| 164 | a.menu.SetMenus(a.allMenus()) |
| 165 | } |