// Package app assembles the editor: it owns the screen, the desktop of // windows, the menu bar, the status bar and the modal stack, and it routes // every key and click to whichever of them should see it. // // Everything below it is reusable on its own; this package is where the // decisions about *this* editor live. package app import ( "context" "fmt" "sync/atomic" "time" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/editor" "codeberg.org/turbo-editors/turbo-core/filetree" "codeberg.org/turbo-editors/turbo-core/profile" "codeberg.org/turbo-editors/turbo-core/settings" "codeberg.org/turbo-editors/turbo-core/terminal" "codeberg.org/turbo-editors/turbo-core/theme" "codeberg.org/turbo-editors/turbo-core/ui" ) // menuBarHeight and statusBarHeight are the rows the furniture takes off the // top and bottom of the screen. const ( menuBarHeight = 1 statusBarHeight = 1 newWindowOffset = 2 // each new window is offset from the last maxWindowOffsets = 8 ) // modal is a dialog waiting for an answer, and what to do with it. type modal struct { dialog *ui.Dialog onClose func(ui.Result) } // App is the running editor. type App struct { screen tcell.Screen // profile is which editor this is: its name, its language, its own // directory, its language server, the files it offers to create. Everything // that would otherwise be a hardcoded "turbo-go" is read from here. profile profile.Profile theme *theme.Theme themeName string desktop *ui.Desktop menu *ui.MenuBar status *ui.StatusBar modals []modal completion *CompletionBox clipboard *editor.Clipboard language *Language // terminals maps a window to the shell inside it. A window that is not in // here holds a file. terminals map[*ui.Window]*terminal.View // agents maps a window to the conversation inside it. A window in neither // this nor terminals holds a file. agents map[*ui.Window]*agentWindow // permissions are the agents waiting for somebody to answer them. It is // written from each session's reading goroutine and read by the event // loop, which is why it carries its own lock. permissions pendingPermissions // agentFiles guards the open buffers against being read by a session's // goroutine while the event loop is writing them. agentFiles agentFileLock // running is the command whose output is showing in a dialog, when one is. running *toolRun // toolValues is what was last typed into a tool's parameters box, by tool // name and then by label. It lives for the session only: a value somebody // typed this afternoon is not a decision the project made, so it does not // belong in the project's own directory. toolValues map[string]map[string]string // toolsRan says a command from the Go menu has finished, so the files it // may have rewritten want re-reading. It is set from the reading goroutine // and cleared by the event loop, which is why it is atomic. toolsRan atomic.Bool // toolsStamp is what the tools file looked like when the menu bar was last // built. The menus the file asks for are part of the bar, so a change to it // has to rebuild the bar and not only the items inside a menu. toolsStamp fileStamp // treeWindow and treeView are the project tree, when one is open. There is // at most one of them, so a pair of fields says it better than a map. treeWindow *ui.Window treeView *filetree.View // settingsPath is the project settings file in use, or "" when the project // has none. It is where a setting changed in the editor is written back. settingsPath string // autosave is the pending automatic save, when the project asked for one. autosave autosave // now is the clock, so that autosave's deadlines can be tested without // waiting for them. now func() time.Time lastSearch string lastMatchCase bool windowsOpened int announced bool // the open documents have been told to the server quitting bool } // New returns an editor drawing on screen, with the named theme, being the // editor the profile describes. // // An unknown theme name falls back to the default rather than failing: a typo // in a configuration file should not stop the editor opening. // // editor := app.New(screen, "turbo-dark", golang.Profile()) // editor.NewFile() // editor.Run() func New(screen tcell.Screen, themeName string, p profile.Profile) *App { a := &App{ screen: screen, profile: p, desktop: ui.NewDesktop(), status: ui.NewStatusBar(), completion: &CompletionBox{}, clipboard: &editor.Clipboard{}, language: NewLanguage(p.Server, p.Name), terminals: map[*ui.Window]*terminal.View{}, agents: map[*ui.Window]*agentWindow{}, now: time.Now, } a.autosave.delay = settings.DefaultAutosaveDelay a.setTheme(themeName) // Stamped before the bar is built, so a tools file written between the two // is picked up by the next turn of the loop rather than missed. a.toolsStamp = a.toolsFileStamp() a.menu = a.buildMenus() a.status.SetItems(a.statusItems()...) a.completion.OnAccept = a.acceptCompletion a.language.OnUpdate = a.wake return a } // Profile returns which editor this is: its name, its language, and everything // else that differs between one editor built on this library and another. func (a *App) Profile() profile.Profile { return a.profile } // Theme returns the theme currently in use. func (a *App) Theme() *theme.Theme { return a.theme } // ThemeName returns the name of the theme currently in use. func (a *App) ThemeName() string { return a.themeName } // UseSettings applies a project's settings and remembers where they came // from, so that changing a setting in the editor can be written back. // // The theme is not applied here: the caller resolves it first, because a // -theme flag on the command line overrides the project's choice and only the // caller knows whether one was given. // // s, err := settings.Load(p, dir) // if err == nil { // editor.UseSettings(s, settings.Path(p, dir)) // } func (a *App) UseSettings(s settings.Settings, path string) { a.settingsPath = path a.SetAutosave(s.Autosave, s.AutosaveDelay) } // SettingsPath returns the project settings file the editor is following, or // "" when the project has none. func (a *App) SettingsPath() string { return a.settingsPath } // setTheme loads a theme by name, falling back to the default. func (a *App) setTheme(name string) { loaded, err := theme.Load(name, a.profile.ThemeDir()) if err != nil { a.theme, a.themeName = theme.Default(a.profile.ThemeDir()), theme.DefaultName } else { a.theme, a.themeName = loaded, name } a.applyCursorStyle() } // applyCursorStyle tells the terminal to draw its cursor as a solid block in // the theme's own colour. // // This matters more than it sounds. A terminal draws its cursor *over* the // cell, in whatever colour the user configured for some other palette, and on // a dark theme that is very often a dark cursor on a dark background — a cell // painted underneath does not help, because the terminal's block covers it. // Naming the colour here is the only way the theme can win. // // Terminals that support neither the shape nor the colour ignore this, which // is why the cell underneath is still painted as a fallback. func (a *App) applyCursorStyle() { a.screen.SetCursorStyle(tcell.CursorStyleSteadyBlock, cursorColor(a.theme)) } // cursorColor returns the colour a block cursor should be filled with: the // background of the theme's cursor style, since that is what a filled block // shows. func cursorColor(th *theme.Theme) tcell.Color { _, background, _ := th.Style(theme.KeyEditorCursor).Decompose() return background } // StartLanguageServer starts the editor's language server in root, in the // background so that a slow start-up does not hold the first keystroke up. func (a *App) StartLanguageServer(ctx context.Context, root string) { go a.language.Start(ctx, root) } // announceOpenDocuments tells the language server about every file that is // already open, the first time there is anybody to tell. // // The command opens the files named on its command line and *then* starts the // server, so the didOpen sent at that moment reaches nothing. Without this // second announcement the server never learns the documents exist, ignores // every didChange that follows, and answers completions from whatever is on // disk — which is to say, not from what has been typed. // // This is checked on every turn of the event loop rather than driven by an // event, because an event can be dropped: tcell's queue is bounded, PostEvent // fails when it is full, and start-up — when gopls publishes diagnostics for // the whole module — is exactly when it is fullest. Correctness must not // depend on a message that is allowed to go missing. func (a *App) announceOpenDocuments() { if a.announced || !a.language.Ready() { return } a.announced = true for _, window := range a.desktop.Windows() { view, ok := window.Content().(*editor.View) if !ok { continue } a.language.DidOpen(view.Buffer().Path(), view.Buffer().Text()) } } // Language returns the language-server side of the editor. func (a *App) Language() *Language { return a.language } // Run draws and handles events until the user asks to leave. func (a *App) Run() error { for !a.quitting { a.tick() a.layout() a.draw() event := a.screen.PollEvent() if event == nil { return nil // the screen was finalised from under us } a.handle(event) } return nil } // tick is the work the loop does before it draws, every turn. // // Everything here is **state-driven**: each of these looks at what is true now // rather than waiting to be told. That is deliberate and it is the same reason // each time — the only way to wake this loop from another goroutine is // PostEvent, which drops what does not fit in its queue, so anything that // depended on a message arriving would fail exactly when the editor is busiest. // A dropped wake-up here costs a late turn, never a lost one. // // It is a method rather than five lines inside Run so that a test can take one // turn of the loop, which is the only way to test any of it: Run itself blocks // in PollEvent. func (a *App) tick() { a.announceOpenDocuments() a.refreshToolMenus() a.refreshRunningTool() a.reloadAfterTools() a.saveDueDocuments() a.refreshTerminalTitles() a.refreshAgentTitles() a.askNextPermission() a.refreshMarks() } // Tick does one turn of the event loop's state-driven work, without waiting for // an event. // // Run does this for itself before every frame; it is exported for the same // reason Render is, so that an editor built on this library can drive itself // end to end from a test — announcing open documents to a language server that // has just come up, say — with no terminal and no event loop. func (a *App) Tick() { a.tick() } // Handle routes one event through the whole chain, exactly as the event loop // does. It is exported alongside Tick and Render, and for the same reason. // // editor.Handle(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) // editor.Tick() // editor.Render() func (a *App) Handle(event tcell.Event) { a.handle(event) } // ActiveView returns the editing view of the window in front, or nil when the // front window is not a file — a terminal, or the project tree — and when there // is no window at all. func (a *App) ActiveView() *editor.View { return a.activeView() } // Quitting reports whether the editor is on its way out, which is what ends // the event loop. func (a *App) Quitting() bool { return a.quitting } // wake makes the event loop go round again, so that something which happened // off the main goroutine — a batch of diagnostics, say — reaches the screen. // // The post is allowed to fail: a full queue means a redraw is already coming. func (a *App) wake() { _ = a.screen.PostEvent(tcell.NewEventInterrupt(nil)) } // screenRect returns the whole terminal. func (a *App) screenRect() ui.Rect { width, height := a.screen.Size() return ui.Rect{W: width, H: height} } // desktopRect returns the area between the menu bar and the status bar. func (a *App) desktopRect() ui.Rect { screen := a.screenRect() return ui.Rect{ X: 0, Y: menuBarHeight, W: screen.W, H: max(screen.H-menuBarHeight-statusBarHeight, 0), } } // layout places the furniture, which must happen again after every resize. func (a *App) layout() { screen := a.screenRect() a.menu.SetBounds(ui.Rect{W: screen.W, H: menuBarHeight}) a.status.SetBounds(ui.Rect{Y: screen.H - statusBarHeight, W: screen.W, H: statusBarHeight}) a.desktop.SetBounds(a.desktopRect()) } // draw paints the whole screen, back to front. func (a *App) draw() { painter := ui.NewPainter(a.screen) a.screen.HideCursor() a.desktop.Draw(painter.Sub(a.desktop.Bounds()), a.theme) a.updateStatus() a.status.Draw(painter, a.theme) a.menu.Draw(painter, a.theme) for _, m := range a.modals { m.dialog.Draw(painter, a.theme) } a.completion.Draw(painter, a.theme) a.screen.Show() } // updateStatus refreshes what the status bar shows on the right: the cursor // position, and whatever the language server has to say. func (a *App) updateStatus() { view := a.activeView() if view == nil { a.status.SetExtra(a.language.Status()) return } extra := view.CursorStatus() if diagnostic, ok := a.language.FirstError(view.Buffer().Path()); ok { extra = fmt.Sprintf("%s ⚠ %s", extra, diagnostic.Message) } else { extra = fmt.Sprintf("%s %s", extra, a.language.Status()) } a.status.SetExtra(extra) } // handle routes one event. func (a *App) handle(event tcell.Event) { switch typed := event.(type) { case *tcell.EventResize: a.resize() case *tcell.EventKey: a.handleKey(typed) case *tcell.EventMouse: a.handleMouse(typed) } a.announceOpenDocuments() a.settleModals() } // resize takes everything that is not the desktop to the new terminal size. // // The desktop and its windows follow in layout, on the next turn of the loop. // What needs doing here is the floating furniture: a dialog placed in the // middle of the old screen would be off-centre or half outside the new one, // and the completion popup is anchored to a cursor that has just moved. func (a *App) resize() { a.screen.Sync() a.completion.Hide() for _, m := range a.modals { m.dialog.CenterIn(a.screenRect()) } } // handleKey offers a key to each layer in turn, front to back, and gives what // nobody claimed to the window on the desktop. func (a *App) handleKey(ev *tcell.EventKey) { a.status.SetMessage("") // any keystroke clears a transient message for _, claims := range a.keyLayers() { if claims(ev) { return } } if a.desktop.HandleKey(ev) { a.refreshCompletionPrefix() } } // keyLayers returns everything that may claim a key press before the window in // front does, in the order they are offered it. // // The order is the design. An open completion list is what the next key is // about; a modal dialog is exclusive by definition; an open menu owns the // keyboard for as long as it is down; a focused terminal wants nearly // everything, so it comes before the editor's own shortcuts; and the closed // menu's Alt-letters and F10 come after the terminal precisely so a shell can // have Alt-B and Alt-F. func (a *App) keyLayers() []func(*tcell.EventKey) bool { return []func(*tcell.EventKey) bool{ a.completion.HandleKey, a.handleModalKey, a.handleOpenMenuKey, a.handleTerminalKey, a.menu.HandleKey, a.handleShortcut, } } // handleModalKey gives a key to the topmost dialog, and reports that it was // taken whether or not the dialog did anything with it: a modal swallows the // keyboard for as long as it is up. func (a *App) handleModalKey(ev *tcell.EventKey) bool { if len(a.modals) == 0 { return false } a.modals[len(a.modals)-1].dialog.HandleKey(ev) return true } // handleOpenMenuKey gives a key to the menu bar only while a menu is down. func (a *App) handleOpenMenuKey(ev *tcell.EventKey) bool { return a.menu.Open() && a.menu.HandleKey(ev) } // handleTerminalKey sends a key to the shell in the front window. func (a *App) handleTerminalKey(ev *tcell.EventKey) bool { return a.terminalTakesKey(ev) && a.desktop.HandleKey(ev) } // terminalTakesKey reports whether a key belongs to the shell in the front // window rather than to the editor. // // A shell needs nearly every key: Ctrl-C interrupts, Ctrl-W deletes a word, // Ctrl-F moves forward, Alt-B moves back. Letting the editor keep those would // make the terminal useless. What the editor keeps is the handful of keys that // are the way *out* of a terminal — see editorOwnedKey. func (a *App) terminalTakesKey(ev *tcell.EventKey) bool { return a.activeTerminal() != nil && !editorOwnedKey(ev) } // editorOwnedKey reports whether the editor keeps a key even when a terminal // has the focus: the function keys, Alt-X to leave, and Alt-digit to switch // windows. // // Without these there would be no way out of a full-screen program running in // a terminal window. func editorOwnedKey(ev *tcell.EventKey) bool { if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 { return true } if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 { return false } r := ev.Rune() return r == 'x' || r == 'X' || (r >= '0' && r <= '9') } // handleMouse offers a click to each layer in turn, front to back. func (a *App) handleMouse(ev *tcell.EventMouse) { if a.completion.HandleMouse(ev) { return } if len(a.modals) > 0 { a.modals[len(a.modals)-1].dialog.HandleMouse(ev) return } if a.menu.HandleMouse(ev) { return } if a.status.HandleMouse(ev) { return } a.desktop.HandleMouse(ev) } // handleShortcut runs the editor's global keys and reports whether it did. func (a *App) handleShortcut(ev *tcell.EventKey) bool { if a.handleWindowNumber(ev) { return true } switch { case ev.Key() == tcell.KeyF1: a.DescribeSymbol() case ev.Key() == tcell.KeyF2: a.SaveFile() case ev.Key() == tcell.KeyF3: a.OpenFile() case ev.Key() == tcell.KeyF4: a.NewFile() case ev.Key() == tcell.KeyF6: a.NextWindow() case ev.Key() == tcell.KeyF8: a.NewTerminal() case ev.Key() == tcell.KeyF9: a.ProjectTree() case ev.Key() == tcell.KeyF7 && ev.Modifiers()&tcell.ModShift != 0: a.FindPrevious() case ev.Key() == tcell.KeyF7: a.FindNext() case ev.Key() == tcell.KeyF12 && ev.Modifiers()&tcell.ModShift != 0: a.FindReferences() case ev.Key() == tcell.KeyF12: a.GoToDefinition() case ev.Key() == tcell.KeyCtrlT: a.SymbolInProject() case ev.Key() == tcell.KeyCtrlF: a.Find() case ev.Key() == tcell.KeyCtrlG: a.GoToLine() case ev.Key() == tcell.KeyCtrlW: a.CloseFile() case ev.Key() == tcell.KeyRune && ev.Modifiers()&tcell.ModAlt != 0 && (ev.Rune() == 'x' || ev.Rune() == 'X'): a.Quit() default: return false } return true } // handleWindowNumber deals with Alt-1 … Alt-9, which pick a window, and Alt-0, // which lists them. func (a *App) handleWindowNumber(ev *tcell.EventKey) bool { if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 { return false } digit := ev.Rune() - '0' if digit < 0 || digit > 9 { return false } if digit == 0 { a.ListWindows() return true } return a.desktop.FocusNumber(int(digit)) } // pushModal puts a dialog in front of everything else. func (a *App) pushModal(dialog *ui.Dialog, onClose func(ui.Result)) { a.completion.Hide() a.modals = append(a.modals, modal{dialog: dialog, onClose: onClose}) } // settleModals closes the dialogs that have been answered and runs what they // were waiting on. // // It runs after every event rather than inside the handlers, so that a dialog // which opens another one does not nest event handling. func (a *App) settleModals() { for len(a.modals) > 0 { top := a.modals[len(a.modals)-1] if !top.dialog.Done() { return } a.modals = a.modals[:len(a.modals)-1] if top.onClose != nil { top.onClose(top.dialog.Result()) } } } // Message shows a one-line note on the status bar. func (a *App) Message(text string) { a.status.SetMessage(text) } // ShowMessage opens a box with a message in it. func (a *App) ShowMessage(title, message string) { a.pushModal(NewMessageDialog(title, message, a.screenRect()), nil) } // activeView returns the editor view of the front window, or nil when no file // is open. func (a *App) activeView() *editor.View { window := a.desktop.Active() if window == nil { return nil } view, _ := editorViewOf(window) return view } // Modals returns how many dialogs are waiting, which the tests use to see that // an action opened one. func (a *App) Modals() int { return len(a.modals) } // TopModal returns the dialog in front, or nil when there is none. func (a *App) TopModal() *ui.Dialog { if len(a.modals) == 0 { return nil } return a.modals[len(a.modals)-1].dialog } // Desktop returns the desktop, so that tests and the main program can look at // the windows without going through the screen. func (a *App) Desktop() *ui.Desktop { return a.desktop } // Completion returns the completion popup. func (a *App) Completion() *CompletionBox { return a.completion } // MenuBar returns the menu bar. func (a *App) MenuBar() *ui.MenuBar { return a.menu } // Render lays the editor out and paints one frame. The event loop does this // for itself; it is exported so a screenshot can be taken without one. func (a *App) Render() { a.layout() a.draw() } // StatusBar returns the status bar. func (a *App) StatusBar() *ui.StatusBar { return a.status }