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.

📦 Turbo Core f3ade8d · on v1.0.1 · k33g · 11h ago
app.go · 660 lines · 21.1 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
// 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"

	"rickub.com/turbo-editors/turbo-core/editor"
	"rickub.com/turbo-editors/turbo-core/filetree"
	"rickub.com/turbo-editors/turbo-core/profile"
	"rickub.com/turbo-editors/turbo-core/settings"
	"rickub.com/turbo-editors/turbo-core/terminal"
	"rickub.com/turbo-editors/turbo-core/theme"
	"rickub.com/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 }