// The menus the project's tools file asks for: the editor's own toolchain menu, // and any others a tool names for itself. package app import ( "os" "time" "unicode" "codeberg.org/turbo-editors/turbo-core/tools" "codeberg.org/turbo-editors/turbo-core/ui" ) // allMenus returns the whole bar: the fixed menus, then the ones the project's // tools file asks for, then Help. // // Help stays last because that is where Turbo C put it and where a reader // looks for it. The project's own menus land between it and Go, which is where // a menu grown out of the file belongs — after everything the editor itself // offers. func (a *App) allMenus() []*ui.Menu { fixed := []*ui.Menu{ a.fileMenu(), a.editMenu(), a.searchMenu(), a.runMenu(), a.codeMenu(), a.optionsMenu(), a.windowMenu(), a.snippetsMenu(), a.agentMenu(), a.toolchainMenu(), } help := a.helpMenu() menus := make([]*ui.Menu, 0, len(fixed)+1) menus = append(menus, fixed...) menus = append(menus, a.toolMenus(takenHotKeys(append(fixed, help)))...) return append(menus, help) } // toolMenus returns one menu per name the tools file uses, in the order the // names first appear in it, skipping the editor's own because it is already on // the bar. // // A file that cannot be read gives no menus at all. The toolchain menu shows // the error instead: a broken file should say so once, not once per menu it was // about to create. func (a *App) toolMenus(taken map[rune]bool) []*ui.Menu { list, err := a.loadTools() if err != nil { return nil } var menus []*ui.Menu for _, name := range list.MenuNames() { if name == a.toolsMenuName() { continue } menus = append(menus, a.toolMenu(name, hotKeyLabel(name, taken))) } return menus } // toolMenu returns a menu holding the tools that named it. // // Like the toolchain menu, its items come from OnOpen: they are read from a // file that changes while the editor runs. func (a *App) toolMenu(name, label string) *ui.Menu { menu := &ui.Menu{Label: label} menu.OnOpen = func() { menu.Items = a.commandItems(name) } return menu } // takenHotKeys returns the hot keys the given menus already claim. func takenHotKeys(menus []*ui.Menu) map[rune]bool { taken := make(map[rune]bool, len(menus)) for _, menu := range menus { if _, key, index := ui.SplitHotKey(menu.Label); index >= 0 { taken[unicode.ToLower(key)] = true } } return taken } // hotKeyLabel marks a menu name's first letter that no other menu has claimed, // and reserves it. // // The bar answers the *first* menu whose hot key matches, so two menus sharing // one silently make the second unreachable. A name written with the marker // already in it — `menu = "~T~ools"` — keeps its own letter when it is free; // when it is not, and when every letter of the name is spoken for, the menu // gets no hot key at all rather than one that opens something else. F10 and // the mouse still reach it. // // taken := map[rune]bool{'f': true} // hotKeyLabel("Format", taken) // "F~o~rmat" func hotKeyLabel(name string, taken map[rune]bool) string { if plain, key, index := ui.SplitHotKey(name); index >= 0 { if lower := unicode.ToLower(key); !taken[lower] { taken[lower] = true return name } name = plain // their letter is spoken for; find another } runes := []rune(name) for i, r := range runes { lower := unicode.ToLower(r) if !unicode.IsLetter(r) || taken[lower] { continue } taken[lower] = true marker := string(ui.HotKeyMarker) return string(runes[:i]) + marker + string(r) + marker + string(runes[i+1:]) } return name } // fileStamp is what a file looked like at a moment: enough to tell that it has // changed without reading it. type fileStamp struct { exists bool size int64 modTime time.Time } // stampOf describes a file. A file that is not there stamps as the zero value, // so appearing and disappearing both register as changes. func stampOf(path string) fileStamp { info, err := os.Stat(path) if err != nil { return fileStamp{} } return fileStamp{exists: true, size: info.Size(), modTime: info.ModTime()} } // toolsFileStamp describes the project's tools file. func (a *App) toolsFileStamp() fileStamp { working, err := os.Getwd() if err != nil { return fileStamp{} } return stampOf(tools.Path(a.profile, working)) } // refreshToolMenus puts the bar back in step with the tools file. // // Menu.OnOpen refills a menu's items, but the *set* of menus comes from the // file as well: adding `menu = "Docker"` to it should put a Docker menu on the // bar without restarting the editor. Rebuilding is guarded by the file's size // and modification time, so the usual case — nothing changed — costs one stat // and no parsing. // // It runs at the top of the event loop, with the rest of the state-driven // work, because rebuilding the bar from a goroutine would race the draw. func (a *App) refreshToolMenus() { stamp := a.toolsFileStamp() if stamp == a.toolsStamp { return } a.toolsStamp = stamp a.menu.SetMenus(a.allMenus()) }