turbo-editors/turbo-corepublic Fork 0
main
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.

window.go · 446 lines · 13.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 4h ago1package ui
2
3import (
4 "strconv"
5
6 "github.com/gdamore/tcell/v2"
7
8 "codeberg.org/turbo-editors/turbo-core/theme"
9)
10
11// MinWindowWidth and MinWindowHeight are the smallest a window may be dragged
12// down to: enough for a frame and one cell of content.
13const (
14 MinWindowWidth = 12
15 MinWindowHeight = 4
16)
17
18// Grow says which edges of the desktop a window follows when the desktop is
19// resized — Turbo Vision called this a window's grow mode.
20//
21// A document window follows the right and bottom edges, so its top-left corner
22// stays put while its far corner keeps pace with the terminal. That is what
23// makes the editor fill a window you have just made larger.
24type Grow uint8
25
26// The edges a window can follow.
27const (
28 GrowNone Grow = 0
29 GrowRight Grow = 1 << iota // the right edge keeps its distance from the desktop's
30 GrowBottom // the bottom edge keeps its distance from the desktop's
31
32 // GrowBoth is what a document window uses.
33 GrowBoth = GrowRight | GrowBottom
34)
35
36// Window is a framed, movable, resizable box holding one widget.
37//
38// The frame carries the Turbo Vision furniture: a close box at the top left, a
39// title, the window's number so that Alt-1 … Alt-9 can reach it, and a
40// maximise box at the top right. The frame is drawn double when the window is
41// active and single when it is not, which is how the eye finds the focused
42// window even in a monochrome terminal.
43type Window struct {
44 Box
45 title string
46 number int
47 active bool
48 closable bool
49 grow Grow
50 content Widget
51
52 // OnClose is called when the close box is used. It returns whether the
53 // window may actually go, so an editor can put a "save first?" dialog in
54 // the way.
55 OnClose func() bool
56
57 // OnMaximize is called when the maximise box is used.
58 //
59 // A window with nobody listening draws no maximise box, because the window
60 // itself cannot know what to fill: only whatever holds it does. Desktop.Add
61 // sets this, so a window on a desktop always has a working one.
62 OnMaximize func()
63
64 // maximized says whether the window is currently filling its desktop, and
65 // restore is where it was before it did.
66 maximized bool
67 restore Rect
68
69 drag dragState
70}
71
72// dragState remembers what a mouse drag on the frame is doing.
73type dragState struct {
74 mode dragMode
75 startX int
76 startY int
77 origin Rect
78}
79
80// dragMode is what the current mouse drag is changing.
81type dragMode int
82
83const (
84 dragNone dragMode = iota
85 dragMove
86 dragResize
87)
88
89// NewWindow returns a window titled title, holding content.
90//
91// w := ui.NewWindow("main.go", editorView)
92// w.SetBounds(ui.Rect{X: 0, Y: 1, W: 60, H: 20})
93func NewWindow(title string, content Widget) *Window {
94 return &Window{title: title, content: content, closable: true, grow: GrowBoth}
95}
96
97// Grow returns which desktop edges this window follows.
98func (w *Window) Grow() Grow { return w.grow }
99
100// SetGrow chooses which desktop edges this window follows. A window set to
101// GrowNone keeps its size and is only moved back into view.
102func (w *Window) SetGrow(grow Grow) { w.grow = grow }
103
104// fitInto returns where the window belongs after the desktop has changed from
105// one rectangle to another.
106//
107// The edges the window follows move by the same amount the desktop's did, so
108// the gap between them is preserved. Whatever the result, the window is then
109// held to the desktop's own size: one larger than the desktop that holds it
110// has parts nobody can reach.
111func (w *Window) fitInto(from, to Rect) Rect { return w.fitRect(w.Bounds(), from, to) }
112
113// fitRect is fitInto for any rectangle belonging to this window, which is what
114// lets the bounds a maximised window would be restored to travel with it.
115func (w *Window) fitRect(bounds, from, to Rect) Rect {
116 if !from.IsEmpty() {
117 if w.grow&GrowRight != 0 {
118 bounds.W += to.Right() - from.Right()
119 }
120 if w.grow&GrowBottom != 0 {
121 bounds.H += to.Bottom() - from.Bottom()
122 }
123 }
124
125 bounds.W = min(max(bounds.W, MinWindowWidth), to.W)
126 bounds.H = min(max(bounds.H, MinWindowHeight), to.H)
127 return bounds.ClampInto(to)
128}
129
130// followDesktop moves the window as the desktop changes size, taking the
131// bounds it would be restored to along with it.
132//
133// Without the second part, maximising and then making the terminal smaller
134// would leave a restore rectangle larger than the desktop — and pressing the
135// box would put the window somewhere nobody could reach.
136func (w *Window) followDesktop(from, to Rect) {
137 if w.maximized {
138 w.restore = w.fitRect(w.restore, from, to)
139 }
140 w.SetBounds(w.fitInto(from, to))
141}
142
143// place puts a window at a rectangle and forgets that it was maximised.
144//
145// A window the desktop has just laid out is no longer filling anything, so its
146// box must go back to offering to maximise rather than to restore a rectangle
147// that no longer means anything.
148func (w *Window) place(r Rect) {
149 w.maximized = false
150 w.SetBounds(r)
151}
152
153// Title returns the window's title.
154func (w *Window) Title() string { return w.title }
155
156// SetTitle changes the window's title, as saving under a new name does.
157func (w *Window) SetTitle(title string) { w.title = title }
158
159// Number returns the window's position in the desktop, from one, or zero when
160// it has none. It is what Alt-1 … Alt-9 select.
161func (w *Window) Number() int { return w.number }
162
163// SetNumber records the window's position in the desktop.
164func (w *Window) SetNumber(n int) { w.number = n }
165
166// Active reports whether this is the window the keyboard talks to.
167func (w *Window) Active() bool { return w.active }
168
169// SetActive marks the window as the focused one, which changes how its frame
170// is drawn.
171//
172// A content widget that can hold the focus is told as well, so that only the
173// active window draws a cursor. Without that, every open window would show one
174// and none of them would mean anything.
175func (w *Window) SetActive(active bool) {
176 w.active = active
177 if focusable, ok := w.content.(Focusable); ok {
178 focusable.SetFocused(active)
179 }
180}
181
182// Closable reports whether the window shows a close box.
183func (w *Window) Closable() bool { return w.closable }
184
185// SetClosable shows or hides the close box.
186func (w *Window) SetClosable(closable bool) { w.closable = closable }
187
188// Content returns the widget filling the window's interior.
189func (w *Window) Content() Widget { return w.content }
190
191// SetBounds places the window and lays its content out inside the frame.
192func (w *Window) SetBounds(r Rect) {
193 w.Box.SetBounds(r)
194 if w.content != nil {
195 w.content.SetBounds(r.Inset(1, 1))
196 }
197}
198
199// InteriorBounds returns the rectangle inside the frame.
200func (w *Window) InteriorBounds() Rect { return w.Bounds().Inset(1, 1) }
201
202// Draw paints the frame, the title bar and the content.
203func (w *Window) Draw(p *Painter, th *theme.Theme) {
204 bounds := w.Bounds()
205 if bounds.W < 2 || bounds.H < 2 {
206 return
207 }
208
209 DrawShadow(p, bounds, th.Style(theme.KeyShadow))
210
211 frame := p.Sub(bounds)
212 frame.Clear(th.Style(theme.KeyWindowBody))
213 DrawFrame(frame, frame.Size(), w.frameKind(), th.Style(w.frameStyleKey()))
214 w.drawTitleBar(frame, th)
215 w.drawNumber(frame, th)
216
217 if w.content != nil {
218 w.content.Draw(p.Sub(w.InteriorBounds()), th)
219 }
220}
221
222// frameKind returns the box-drawing style for the window's current state.
223func (w *Window) frameKind() FrameKind {
224 if w.active {
225 return FrameDouble
226 }
227 return FrameSingle
228}
229
230// frameStyleKey returns the theme key colouring the frame.
231func (w *Window) frameStyleKey() string {
232 if w.active {
233 return theme.KeyWindowFrameActive
234 }
235 return theme.KeyWindowFrameInactive
236}
237
238// titleStyleKey returns the theme key colouring the title.
239func (w *Window) titleStyleKey() string {
240 if w.active {
241 return theme.KeyWindowTitleActive
242 }
243 return theme.KeyWindowTitleInactive
244}
245
246// The two boxes on the top frame. Their widths are counted in **cells**, not
247// bytes: the block characters are three bytes each and occupy one column, and
248// measuring them with len() was once a real off-by-two.
249const (
250 // closeBoxLabel closes the window. It sits at the top left, where Turbo
251 // Vision put it.
252 closeBoxLabel = "[x]"
253 // maximizeBoxLabel fills the desktop; restoreBoxLabel puts the window back
254 // where it was. Only one is ever drawn, so the box says what pressing it
255 // will do rather than what the window currently is.
256 maximizeBoxLabel = "[■]"
257 restoreBoxLabel = "[▬]"
258
259 // boxWidth is how many columns any of them takes on screen.
260 boxWidth = 3
261)
262
263// numberOffset and maximizeOffset are how far from the right edge the window
264// number and the maximise box are drawn.
265//
266// The maximise box occupies the three columns before the corner, and the
267// number sits two further left, so the two never touch.
268const (
269 maximizeOffset = 4
270 numberOffset = 6
271)
272
273// Maximized reports whether the window is filling the area it was maximised
274// into.
275func (w *Window) Maximized() bool { return w.maximized }
276
277// Maximize makes the window fill area, remembering where it was so Restore can
278// put it back.
279//
280// Maximising an already maximised window only moves it to the new area, which
281// is what keeps it filling a terminal that has been resized.
282//
283// desktop := ui.NewDesktop()
284// w.Maximize(desktop.Bounds())
285func (w *Window) Maximize(area Rect) {
286 if !w.maximized {
287 w.restore = w.Bounds()
288 w.maximized = true
289 }
290 w.SetBounds(area)
291}
292
293// Restore puts the window back where it was before Maximize, and does nothing
294// to a window that was never maximised.
295func (w *Window) Restore() {
296 if !w.maximized {
297 return
298 }
299 w.maximized = false
300 w.SetBounds(w.restore)
301}
302
303// maximizeBoxLabelFor returns the box to draw: the one that says what pressing
304// it will do.
305func (w *Window) maximizeBoxLabelFor() string {
306 if w.maximized {
307 return restoreBoxLabel
308 }
309 return maximizeBoxLabel
310}
311
312// drawTitleBar puts the two boxes and the centred title into the top frame.
313func (w *Window) drawTitleBar(p *Painter, th *theme.Theme) {
314 frameStyle := th.Style(w.frameStyleKey())
315
316 if w.closable && p.Size().W > boxWidth+4 {
317 p.Text(2, 0, closeBoxLabel, frameStyle)
318 }
319 if w.OnMaximize != nil && p.Size().W > numberOffset+boxWidth {
320 p.Text(p.Size().W-maximizeOffset, 0, w.maximizeBoxLabelFor(), frameStyle)
321 }
322
323 if w.title == "" {
324 return
325 }
326
327 // The title sits in the middle of the top frame, with a space either side
328 // so the box-drawing characters do not touch the letters. Five cells are
329 // kept clear on the left — corner, frame, close box — and six on the right
330 // for the number, the maximise box and the corner, so the title never runs
331 // into any of them. TestTheTitleNeverRunsIntoTheFurniture checks the sum
332 // against the offsets rather than trusting this arithmetic.
333 const reservedForFurniture = 5 + numberOffset
334 available := p.Size().W - reservedForFurniture
335 if available < 4 {
336 return
337 }
338 text := " " + w.title + " "
339 if len([]rune(text)) > available {
340 text = " " + string([]rune(w.title)[:available-3]) + "… "
341 }
342
343 x := (p.Size().W - len([]rune(text))) / 2
344 p.Text(x, 0, text, th.Style(w.titleStyleKey()))
345}
346
347// drawNumber puts the window's number in the top right of the frame, where
348// Turbo Vision showed it.
349func (w *Window) drawNumber(p *Painter, th *theme.Theme) {
350 if w.number < 1 || w.number > 9 || p.Size().W < numberOffset+boxWidth+1 {
351 return
352 }
353 p.Text(p.Size().W-numberOffset, 0, strconv.Itoa(w.number), th.Style(w.frameStyleKey()))
354}
355
356// HandleKey passes the key to the content; the window itself has no shortcuts
357// of its own, since those belong to the menu bar.
358func (w *Window) HandleKey(ev *tcell.EventKey) bool {
359 return w.content != nil && w.content.HandleKey(ev)
360}
361
362// HandleMouse deals with the frame — closing, moving, resizing — and passes
363// anything landing on the interior to the content.
364func (w *Window) HandleMouse(ev *tcell.EventMouse) bool {
365 if w.drag.mode != dragNone {
366 return w.continueDrag(ev)
367 }
368 if !w.hit(ev) {
369 return false
370 }
371
372 x, y := ev.Position()
373 if w.InteriorBounds().Contains(x, y) {
374 return w.content != nil && w.content.HandleMouse(ev)
375 }
376 if ev.Buttons() != tcell.Button1 {
377 return true // a click on the frame, but not one that starts anything
378 }
379 return w.startFrameAction(x, y)
380}
381
382// startFrameAction reacts to a press on the frame: close, resize or move.
383func (w *Window) startFrameAction(x, y int) bool {
384 bounds := w.Bounds()
385
386 switch {
387 case w.closable && y == bounds.Y && w.onCloseBox(x):
388 w.requestClose()
389 case w.OnMaximize != nil && y == bounds.Y && w.onMaximizeBox(x):
390 w.OnMaximize()
391 case x == bounds.Right()-1 && y == bounds.Bottom()-1:
392 w.beginDrag(dragResize, x, y)
393 case y == bounds.Y:
394 w.beginDrag(dragMove, x, y)
395 }
396 return true
397}
398
399// onCloseBox reports whether a column falls on the close box.
400func (w *Window) onCloseBox(x int) bool {
401 start := w.Bounds().X + 2
402 return x >= start && x < start+boxWidth
403}
404
405// onMaximizeBox reports whether a column falls on the maximise box.
406func (w *Window) onMaximizeBox(x int) bool {
407 start := w.Bounds().Right() - maximizeOffset
408 return x >= start && x < start+boxWidth
409}
410
411// requestClose asks the owner to close the window, if it agreed to be asked.
412func (w *Window) requestClose() {
413 if w.OnClose != nil {
414 w.OnClose()
415 }
416}
417
418// beginDrag records where a move or resize started.
419func (w *Window) beginDrag(mode dragMode, x, y int) {
420 w.drag = dragState{mode: mode, startX: x, startY: y, origin: w.Bounds()}
421}
422
423// continueDrag applies the movement of an in-progress drag and ends it when
424// the button comes back up.
425func (w *Window) continueDrag(ev *tcell.EventMouse) bool {
426 x, y := ev.Position()
427 dx, dy := x-w.drag.startX, y-w.drag.startY
428
429 if w.drag.mode == dragMove {
430 w.SetBounds(w.drag.origin.Move(dx, dy))
431 } else {
432 w.SetBounds(w.drag.origin.WithSize(
433 max(w.drag.origin.W+dx, MinWindowWidth),
434 max(w.drag.origin.H+dy, MinWindowHeight),
435 ))
436 }
437
438 if ev.Buttons() == tcell.ButtonNone {
439 w.drag = dragState{}
440 }
441 return true
442}
443
444// Dragging reports whether a move or resize is in progress, which the desktop
445// needs in order to keep sending it events that fall outside the window.
446func (w *Window) Dragging() bool { return w.drag.mode != dragNone }