turbo-editors/turbo-corepublic Fork 0
v1.0.1
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

app.go · 660 lines · 21.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 19h ago1// Package app assembles the editor: it owns the screen, the desktop of
2// windows, the menu bar, the status bar and the modal stack, and it routes
3// every key and click to whichever of them should see it.
4//
5// Everything below it is reusable on its own; this package is where the
6// decisions about *this* editor live.
7package app
8
9import (
10 "context"
11 "fmt"
12 "sync/atomic"
13 "time"
14
15 "github.com/gdamore/tcell/v2"
16
📦 Turbo Core f3ade8d k33g 12h ago17 "rickub.com/turbo-editors/turbo-core/editor"
18 "rickub.com/turbo-editors/turbo-core/filetree"
19 "rickub.com/turbo-editors/turbo-core/profile"
20 "rickub.com/turbo-editors/turbo-core/settings"
21 "rickub.com/turbo-editors/turbo-core/terminal"
22 "rickub.com/turbo-editors/turbo-core/theme"
23 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 19h ago24)
25
26// menuBarHeight and statusBarHeight are the rows the furniture takes off the
27// top and bottom of the screen.
28const (
29 menuBarHeight = 1
30 statusBarHeight = 1
31 newWindowOffset = 2 // each new window is offset from the last
32 maxWindowOffsets = 8
33)
34
35// modal is a dialog waiting for an answer, and what to do with it.
36type modal struct {
37 dialog *ui.Dialog
38 onClose func(ui.Result)
39}
40
41// App is the running editor.
42type App struct {
43 screen tcell.Screen
44
45 // profile is which editor this is: its name, its language, its own
46 // directory, its language server, the files it offers to create. Everything
47 // that would otherwise be a hardcoded "turbo-go" is read from here.
48 profile profile.Profile
49
50 theme *theme.Theme
51 themeName string
52
53 desktop *ui.Desktop
54 menu *ui.MenuBar
55 status *ui.StatusBar
56 modals []modal
57 completion *CompletionBox
58
59 clipboard *editor.Clipboard
60 language *Language
61
62 // terminals maps a window to the shell inside it. A window that is not in
63 // here holds a file.
64 terminals map[*ui.Window]*terminal.View
65
66 // agents maps a window to the conversation inside it. A window in neither
67 // this nor terminals holds a file.
68 agents map[*ui.Window]*agentWindow
69
70 // permissions are the agents waiting for somebody to answer them. It is
71 // written from each session's reading goroutine and read by the event
72 // loop, which is why it carries its own lock.
73 permissions pendingPermissions
74
75 // agentFiles guards the open buffers against being read by a session's
76 // goroutine while the event loop is writing them.
77 agentFiles agentFileLock
78
79 // running is the command whose output is showing in a dialog, when one is.
80 running *toolRun
81
82 // toolValues is what was last typed into a tool's parameters box, by tool
83 // name and then by label. It lives for the session only: a value somebody
84 // typed this afternoon is not a decision the project made, so it does not
85 // belong in the project's own directory.
86 toolValues map[string]map[string]string
87
88 // toolsRan says a command from the Go menu has finished, so the files it
89 // may have rewritten want re-reading. It is set from the reading goroutine
90 // and cleared by the event loop, which is why it is atomic.
91 toolsRan atomic.Bool
92
93 // toolsStamp is what the tools file looked like when the menu bar was last
94 // built. The menus the file asks for are part of the bar, so a change to it
95 // has to rebuild the bar and not only the items inside a menu.
96 toolsStamp fileStamp
97
98 // treeWindow and treeView are the project tree, when one is open. There is
99 // at most one of them, so a pair of fields says it better than a map.
100 treeWindow *ui.Window
101 treeView *filetree.View
102
103 // settingsPath is the project settings file in use, or "" when the project
104 // has none. It is where a setting changed in the editor is written back.
105 settingsPath string
106 // autosave is the pending automatic save, when the project asked for one.
107 autosave autosave
108 // now is the clock, so that autosave's deadlines can be tested without
109 // waiting for them.
110 now func() time.Time
111
112 lastSearch string
113 lastMatchCase bool
114 windowsOpened int
115 announced bool // the open documents have been told to the server
116 quitting bool
117}
118
119// New returns an editor drawing on screen, with the named theme, being the
120// editor the profile describes.
121//
122// An unknown theme name falls back to the default rather than failing: a typo
123// in a configuration file should not stop the editor opening.
124//
125// editor := app.New(screen, "turbo-dark", golang.Profile())
126// editor.NewFile()
127// editor.Run()
128func New(screen tcell.Screen, themeName string, p profile.Profile) *App {
129 a := &App{
130 screen: screen,
131 profile: p,
132 desktop: ui.NewDesktop(),
133 status: ui.NewStatusBar(),
134 completion: &CompletionBox{},
135 clipboard: &editor.Clipboard{},
136 language: NewLanguage(p.Server, p.Name),
137 terminals: map[*ui.Window]*terminal.View{},
138 agents: map[*ui.Window]*agentWindow{},
139 now: time.Now,
140 }
141 a.autosave.delay = settings.DefaultAutosaveDelay
142
143 a.setTheme(themeName)
144 // Stamped before the bar is built, so a tools file written between the two
145 // is picked up by the next turn of the loop rather than missed.
146 a.toolsStamp = a.toolsFileStamp()
147 a.menu = a.buildMenus()
148 a.status.SetItems(a.statusItems()...)
149 a.completion.OnAccept = a.acceptCompletion
150 a.language.OnUpdate = a.wake
151 return a
152}
153
154// Profile returns which editor this is: its name, its language, and everything
155// else that differs between one editor built on this library and another.
156func (a *App) Profile() profile.Profile { return a.profile }
157
158// Theme returns the theme currently in use.
159func (a *App) Theme() *theme.Theme { return a.theme }
160
161// ThemeName returns the name of the theme currently in use.
162func (a *App) ThemeName() string { return a.themeName }
163
164// UseSettings applies a project's settings and remembers where they came
165// from, so that changing a setting in the editor can be written back.
166//
167// The theme is not applied here: the caller resolves it first, because a
168// -theme flag on the command line overrides the project's choice and only the
169// caller knows whether one was given.
170//
171// s, err := settings.Load(p, dir)
172// if err == nil {
173// editor.UseSettings(s, settings.Path(p, dir))
174// }
175func (a *App) UseSettings(s settings.Settings, path string) {
176 a.settingsPath = path
177 a.SetAutosave(s.Autosave, s.AutosaveDelay)
178}
179
180// SettingsPath returns the project settings file the editor is following, or
181// "" when the project has none.
182func (a *App) SettingsPath() string { return a.settingsPath }
183
184// setTheme loads a theme by name, falling back to the default.
185func (a *App) setTheme(name string) {
186 loaded, err := theme.Load(name, a.profile.ThemeDir())
187 if err != nil {
188 a.theme, a.themeName = theme.Default(a.profile.ThemeDir()), theme.DefaultName
189 } else {
190 a.theme, a.themeName = loaded, name
191 }
192 a.applyCursorStyle()
193}
194
195// applyCursorStyle tells the terminal to draw its cursor as a solid block in
196// the theme's own colour.
197//
198// This matters more than it sounds. A terminal draws its cursor *over* the
199// cell, in whatever colour the user configured for some other palette, and on
200// a dark theme that is very often a dark cursor on a dark background — a cell
201// painted underneath does not help, because the terminal's block covers it.
202// Naming the colour here is the only way the theme can win.
203//
204// Terminals that support neither the shape nor the colour ignore this, which
205// is why the cell underneath is still painted as a fallback.
206func (a *App) applyCursorStyle() {
207 a.screen.SetCursorStyle(tcell.CursorStyleSteadyBlock, cursorColor(a.theme))
208}
209
210// cursorColor returns the colour a block cursor should be filled with: the
211// background of the theme's cursor style, since that is what a filled block
212// shows.
213func cursorColor(th *theme.Theme) tcell.Color {
214 _, background, _ := th.Style(theme.KeyEditorCursor).Decompose()
215 return background
216}
217
218// StartLanguageServer starts the editor's language server in root, in the
219// background so that a slow start-up does not hold the first keystroke up.
220func (a *App) StartLanguageServer(ctx context.Context, root string) {
221 go a.language.Start(ctx, root)
222}
223
224// announceOpenDocuments tells the language server about every file that is
225// already open, the first time there is anybody to tell.
226//
227// The command opens the files named on its command line and *then* starts the
228// server, so the didOpen sent at that moment reaches nothing. Without this
229// second announcement the server never learns the documents exist, ignores
230// every didChange that follows, and answers completions from whatever is on
231// disk — which is to say, not from what has been typed.
232//
233// This is checked on every turn of the event loop rather than driven by an
234// event, because an event can be dropped: tcell's queue is bounded, PostEvent
235// fails when it is full, and start-up — when gopls publishes diagnostics for
236// the whole module — is exactly when it is fullest. Correctness must not
237// depend on a message that is allowed to go missing.
238func (a *App) announceOpenDocuments() {
239 if a.announced || !a.language.Ready() {
240 return
241 }
242 a.announced = true
243
244 for _, window := range a.desktop.Windows() {
245 view, ok := window.Content().(*editor.View)
246 if !ok {
247 continue
248 }
249 a.language.DidOpen(view.Buffer().Path(), view.Buffer().Text())
250 }
251}
252
253// Language returns the language-server side of the editor.
254func (a *App) Language() *Language { return a.language }
255
256// Run draws and handles events until the user asks to leave.
257func (a *App) Run() error {
258 for !a.quitting {
259 a.tick()
260 a.layout()
261 a.draw()
262
263 event := a.screen.PollEvent()
264 if event == nil {
265 return nil // the screen was finalised from under us
266 }
267 a.handle(event)
268 }
269 return nil
270}
271
272// tick is the work the loop does before it draws, every turn.
273//
274// Everything here is **state-driven**: each of these looks at what is true now
275// rather than waiting to be told. That is deliberate and it is the same reason
276// each time — the only way to wake this loop from another goroutine is
277// PostEvent, which drops what does not fit in its queue, so anything that
278// depended on a message arriving would fail exactly when the editor is busiest.
279// A dropped wake-up here costs a late turn, never a lost one.
280//
281// It is a method rather than five lines inside Run so that a test can take one
282// turn of the loop, which is the only way to test any of it: Run itself blocks
283// in PollEvent.
284func (a *App) tick() {
285 a.announceOpenDocuments()
286 a.refreshToolMenus()
287 a.refreshRunningTool()
288 a.reloadAfterTools()
289 a.saveDueDocuments()
290 a.refreshTerminalTitles()
291 a.refreshAgentTitles()
292 a.askNextPermission()
293 a.refreshMarks()
294}
295
296// Tick does one turn of the event loop's state-driven work, without waiting for
297// an event.
298//
299// Run does this for itself before every frame; it is exported for the same
300// reason Render is, so that an editor built on this library can drive itself
301// end to end from a test — announcing open documents to a language server that
302// has just come up, say — with no terminal and no event loop.
303func (a *App) Tick() { a.tick() }
304
305// Handle routes one event through the whole chain, exactly as the event loop
306// does. It is exported alongside Tick and Render, and for the same reason.
307//
308// editor.Handle(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone))
309// editor.Tick()
310// editor.Render()
311func (a *App) Handle(event tcell.Event) { a.handle(event) }
312
313// ActiveView returns the editing view of the window in front, or nil when the
314// front window is not a file — a terminal, or the project tree — and when there
315// is no window at all.
316func (a *App) ActiveView() *editor.View { return a.activeView() }
317
318// Quitting reports whether the editor is on its way out, which is what ends
319// the event loop.
320func (a *App) Quitting() bool { return a.quitting }
321
322// wake makes the event loop go round again, so that something which happened
323// off the main goroutine — a batch of diagnostics, say — reaches the screen.
324//
325// The post is allowed to fail: a full queue means a redraw is already coming.
326func (a *App) wake() {
327 _ = a.screen.PostEvent(tcell.NewEventInterrupt(nil))
328}
329
330// screenRect returns the whole terminal.
331func (a *App) screenRect() ui.Rect {
332 width, height := a.screen.Size()
333 return ui.Rect{W: width, H: height}
334}
335
336// desktopRect returns the area between the menu bar and the status bar.
337func (a *App) desktopRect() ui.Rect {
338 screen := a.screenRect()
339 return ui.Rect{
340 X: 0,
341 Y: menuBarHeight,
342 W: screen.W,
343 H: max(screen.H-menuBarHeight-statusBarHeight, 0),
344 }
345}
346
347// layout places the furniture, which must happen again after every resize.
348func (a *App) layout() {
349 screen := a.screenRect()
350 a.menu.SetBounds(ui.Rect{W: screen.W, H: menuBarHeight})
351 a.status.SetBounds(ui.Rect{Y: screen.H - statusBarHeight, W: screen.W, H: statusBarHeight})
352 a.desktop.SetBounds(a.desktopRect())
353}
354
355// draw paints the whole screen, back to front.
356func (a *App) draw() {
357 painter := ui.NewPainter(a.screen)
358 a.screen.HideCursor()
359
360 a.desktop.Draw(painter.Sub(a.desktop.Bounds()), a.theme)
361 a.updateStatus()
362 a.status.Draw(painter, a.theme)
363 a.menu.Draw(painter, a.theme)
364
365 for _, m := range a.modals {
366 m.dialog.Draw(painter, a.theme)
367 }
368 a.completion.Draw(painter, a.theme)
369
370 a.screen.Show()
371}
372
373// updateStatus refreshes what the status bar shows on the right: the cursor
374// position, and whatever the language server has to say.
375func (a *App) updateStatus() {
376 view := a.activeView()
377 if view == nil {
378 a.status.SetExtra(a.language.Status())
379 return
380 }
381
382 extra := view.CursorStatus()
383 if diagnostic, ok := a.language.FirstError(view.Buffer().Path()); ok {
384 extra = fmt.Sprintf("%s ⚠ %s", extra, diagnostic.Message)
385 } else {
386 extra = fmt.Sprintf("%s %s", extra, a.language.Status())
387 }
388 a.status.SetExtra(extra)
389}
390
391// handle routes one event.
392func (a *App) handle(event tcell.Event) {
393 switch typed := event.(type) {
394 case *tcell.EventResize:
395 a.resize()
396 case *tcell.EventKey:
397 a.handleKey(typed)
398 case *tcell.EventMouse:
399 a.handleMouse(typed)
400 }
401 a.announceOpenDocuments()
402 a.settleModals()
403}
404
405// resize takes everything that is not the desktop to the new terminal size.
406//
407// The desktop and its windows follow in layout, on the next turn of the loop.
408// What needs doing here is the floating furniture: a dialog placed in the
409// middle of the old screen would be off-centre or half outside the new one,
410// and the completion popup is anchored to a cursor that has just moved.
411func (a *App) resize() {
412 a.screen.Sync()
413 a.completion.Hide()
414
415 for _, m := range a.modals {
416 m.dialog.CenterIn(a.screenRect())
417 }
418}
419
420// handleKey offers a key to each layer in turn, front to back, and gives what
421// nobody claimed to the window on the desktop.
422func (a *App) handleKey(ev *tcell.EventKey) {
423 a.status.SetMessage("") // any keystroke clears a transient message
424
425 for _, claims := range a.keyLayers() {
426 if claims(ev) {
427 return
428 }
429 }
430
431 if a.desktop.HandleKey(ev) {
432 a.refreshCompletionPrefix()
433 }
434}
435
436// keyLayers returns everything that may claim a key press before the window in
437// front does, in the order they are offered it.
438//
439// The order is the design. An open completion list is what the next key is
440// about; a modal dialog is exclusive by definition; an open menu owns the
441// keyboard for as long as it is down; a focused terminal wants nearly
442// everything, so it comes before the editor's own shortcuts; and the closed
443// menu's Alt-letters and F10 come after the terminal precisely so a shell can
444// have Alt-B and Alt-F.
445func (a *App) keyLayers() []func(*tcell.EventKey) bool {
446 return []func(*tcell.EventKey) bool{
447 a.completion.HandleKey,
448 a.handleModalKey,
449 a.handleOpenMenuKey,
450 a.handleTerminalKey,
451 a.menu.HandleKey,
452 a.handleShortcut,
453 }
454}
455
456// handleModalKey gives a key to the topmost dialog, and reports that it was
457// taken whether or not the dialog did anything with it: a modal swallows the
458// keyboard for as long as it is up.
459func (a *App) handleModalKey(ev *tcell.EventKey) bool {
460 if len(a.modals) == 0 {
461 return false
462 }
463 a.modals[len(a.modals)-1].dialog.HandleKey(ev)
464 return true
465}
466
467// handleOpenMenuKey gives a key to the menu bar only while a menu is down.
468func (a *App) handleOpenMenuKey(ev *tcell.EventKey) bool {
469 return a.menu.Open() && a.menu.HandleKey(ev)
470}
471
472// handleTerminalKey sends a key to the shell in the front window.
473func (a *App) handleTerminalKey(ev *tcell.EventKey) bool {
474 return a.terminalTakesKey(ev) && a.desktop.HandleKey(ev)
475}
476
477// terminalTakesKey reports whether a key belongs to the shell in the front
478// window rather than to the editor.
479//
480// A shell needs nearly every key: Ctrl-C interrupts, Ctrl-W deletes a word,
481// Ctrl-F moves forward, Alt-B moves back. Letting the editor keep those would
482// make the terminal useless. What the editor keeps is the handful of keys that
483// are the way *out* of a terminal — see editorOwnedKey.
484func (a *App) terminalTakesKey(ev *tcell.EventKey) bool {
485 return a.activeTerminal() != nil && !editorOwnedKey(ev)
486}
487
488// editorOwnedKey reports whether the editor keeps a key even when a terminal
489// has the focus: the function keys, Alt-X to leave, and Alt-digit to switch
490// windows.
491//
492// Without these there would be no way out of a full-screen program running in
493// a terminal window.
494func editorOwnedKey(ev *tcell.EventKey) bool {
495 if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
496 return true
497 }
498 if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 {
499 return false
500 }
501
502 r := ev.Rune()
503 return r == 'x' || r == 'X' || (r >= '0' && r <= '9')
504}
505
506// handleMouse offers a click to each layer in turn, front to back.
507func (a *App) handleMouse(ev *tcell.EventMouse) {
508 if a.completion.HandleMouse(ev) {
509 return
510 }
511 if len(a.modals) > 0 {
512 a.modals[len(a.modals)-1].dialog.HandleMouse(ev)
513 return
514 }
515 if a.menu.HandleMouse(ev) {
516 return
517 }
518 if a.status.HandleMouse(ev) {
519 return
520 }
521 a.desktop.HandleMouse(ev)
522}
523
524// handleShortcut runs the editor's global keys and reports whether it did.
525func (a *App) handleShortcut(ev *tcell.EventKey) bool {
526 if a.handleWindowNumber(ev) {
527 return true
528 }
529
530 switch {
531 case ev.Key() == tcell.KeyF1:
532 a.DescribeSymbol()
533 case ev.Key() == tcell.KeyF2:
534 a.SaveFile()
535 case ev.Key() == tcell.KeyF3:
536 a.OpenFile()
537 case ev.Key() == tcell.KeyF4:
538 a.NewFile()
539 case ev.Key() == tcell.KeyF6:
540 a.NextWindow()
541 case ev.Key() == tcell.KeyF8:
542 a.NewTerminal()
543 case ev.Key() == tcell.KeyF9:
544 a.ProjectTree()
545 case ev.Key() == tcell.KeyF7 && ev.Modifiers()&tcell.ModShift != 0:
546 a.FindPrevious()
547 case ev.Key() == tcell.KeyF7:
548 a.FindNext()
549 case ev.Key() == tcell.KeyF12 && ev.Modifiers()&tcell.ModShift != 0:
550 a.FindReferences()
551 case ev.Key() == tcell.KeyF12:
552 a.GoToDefinition()
553 case ev.Key() == tcell.KeyCtrlT:
554 a.SymbolInProject()
555 case ev.Key() == tcell.KeyCtrlF:
556 a.Find()
557 case ev.Key() == tcell.KeyCtrlG:
558 a.GoToLine()
559 case ev.Key() == tcell.KeyCtrlW:
560 a.CloseFile()
561 case ev.Key() == tcell.KeyRune && ev.Modifiers()&tcell.ModAlt != 0 && (ev.Rune() == 'x' || ev.Rune() == 'X'):
562 a.Quit()
563 default:
564 return false
565 }
566 return true
567}
568
569// handleWindowNumber deals with Alt-1 … Alt-9, which pick a window, and Alt-0,
570// which lists them.
571func (a *App) handleWindowNumber(ev *tcell.EventKey) bool {
572 if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 {
573 return false
574 }
575 digit := ev.Rune() - '0'
576 if digit < 0 || digit > 9 {
577 return false
578 }
579
580 if digit == 0 {
581 a.ListWindows()
582 return true
583 }
584 return a.desktop.FocusNumber(int(digit))
585}
586
587// pushModal puts a dialog in front of everything else.
588func (a *App) pushModal(dialog *ui.Dialog, onClose func(ui.Result)) {
589 a.completion.Hide()
590 a.modals = append(a.modals, modal{dialog: dialog, onClose: onClose})
591}
592
593// settleModals closes the dialogs that have been answered and runs what they
594// were waiting on.
595//
596// It runs after every event rather than inside the handlers, so that a dialog
597// which opens another one does not nest event handling.
598func (a *App) settleModals() {
599 for len(a.modals) > 0 {
600 top := a.modals[len(a.modals)-1]
601 if !top.dialog.Done() {
602 return
603 }
604 a.modals = a.modals[:len(a.modals)-1]
605 if top.onClose != nil {
606 top.onClose(top.dialog.Result())
607 }
608 }
609}
610
611// Message shows a one-line note on the status bar.
612func (a *App) Message(text string) { a.status.SetMessage(text) }
613
614// ShowMessage opens a box with a message in it.
615func (a *App) ShowMessage(title, message string) {
616 a.pushModal(NewMessageDialog(title, message, a.screenRect()), nil)
617}
618
619// activeView returns the editor view of the front window, or nil when no file
620// is open.
621func (a *App) activeView() *editor.View {
622 window := a.desktop.Active()
623 if window == nil {
624 return nil
625 }
626 view, _ := editorViewOf(window)
627 return view
628}
629
630// Modals returns how many dialogs are waiting, which the tests use to see that
631// an action opened one.
632func (a *App) Modals() int { return len(a.modals) }
633
634// TopModal returns the dialog in front, or nil when there is none.
635func (a *App) TopModal() *ui.Dialog {
636 if len(a.modals) == 0 {
637 return nil
638 }
639 return a.modals[len(a.modals)-1].dialog
640}
641
642// Desktop returns the desktop, so that tests and the main program can look at
643// the windows without going through the screen.
644func (a *App) Desktop() *ui.Desktop { return a.desktop }
645
646// Completion returns the completion popup.
647func (a *App) Completion() *CompletionBox { return a.completion }
648
649// MenuBar returns the menu bar.
650func (a *App) MenuBar() *ui.MenuBar { return a.menu }
651
652// Render lays the editor out and paints one frame. The event loop does this
653// for itself; it is exported so a screenshot can be taken without one.
654func (a *App) Render() {
655 a.layout()
656 a.draw()
657}
658
659// StatusBar returns the status bar.
660func (a *App) StatusBar() *ui.StatusBar { return a.status }