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

dialog.go · 311 lines · 8.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1package ui
2
3import (
4 "github.com/gdamore/tcell/v2"
5
6 "codeberg.org/turbo-editors/turbo-core/theme"
7)
8
9// Result is how a dialog ended.
10type Result int
11
12// The ways a dialog can end.
13const (
14 // ResultNone means the dialog is still open.
15 ResultNone Result = iota
16 ResultOK
17 ResultCancel
18 // ResultNo is the third answer a Yes / No / Cancel question can give:
19 // neither accepting nor cancelling.
20 ResultNo
21)
22
23// Dialog is a modal window holding a ring of controls.
24//
25// While a dialog is open it is offered every event first and nothing behind it
26// sees any, which is what modal means here. Tab and Shift-Tab walk the ring,
27// Escape cancels, and Enter presses the default button from wherever the focus
28// happens to be.
29type Dialog struct {
30 Box
31 title string
32 controls []Focusable
33 focus int
34 result Result
35
36 // OnKey is offered every key before the focused control sees it, so a
37 // dialog can add a shortcut of its own without subclassing anything.
38 OnKey func(ev *tcell.EventKey) bool
39}
40
41// NewDialog returns an open dialog with the given title.
42//
43// d := ui.NewDialog("Open a File")
44// d.Add(nameField)
45// d.Add(ui.NewButton("~O~K", func() { d.Close(ui.ResultOK) }))
46// d.SetBounds(ui.Rect{W: 50, H: 12}.CenteredIn(screen))
47func NewDialog(title string) *Dialog {
48 return &Dialog{title: title, result: ResultNone}
49}
50
51// Title returns the dialog's title.
52func (d *Dialog) Title() string { return d.title }
53
54// SetTitle changes the dialog's title, which a file browser uses to show which
55// directory it is looking at.
56func (d *Dialog) SetTitle(title string) { d.title = title }
57
58// Add puts a control in the dialog and gives the focus to the first one that
59// can take it.
60func (d *Dialog) Add(control Focusable) {
61 d.controls = append(d.controls, control)
62 if len(d.controls) == 1 || !d.controls[d.focus].CanFocus() {
63 d.focusFirst()
64 }
65}
66
67// Controls returns the dialog's controls, in ring order.
68func (d *Dialog) Controls() []Focusable { return d.controls }
69
70// Result returns how the dialog ended, or ResultNone while it is still open.
71func (d *Dialog) Result() Result { return d.result }
72
73// Done reports whether the dialog has been closed.
74func (d *Dialog) Done() bool { return d.result != ResultNone }
75
76// Close ends the dialog with the given result.
77func (d *Dialog) Close(result Result) { d.result = result }
78
79// Focused returns the control the keyboard is talking to, or nil when the
80// dialog holds nothing focusable.
81func (d *Dialog) Focused() Focusable {
82 if d.focus < 0 || d.focus >= len(d.controls) {
83 return nil
84 }
85 return d.controls[d.focus]
86}
87
88// SetFocus gives the focus to control i, if it can take it.
89func (d *Dialog) SetFocus(i int) {
90 if i < 0 || i >= len(d.controls) || !d.controls[i].CanFocus() {
91 return
92 }
93 for _, c := range d.controls {
94 c.SetFocused(false)
95 }
96 d.focus = i
97 d.controls[i].SetFocused(true)
98}
99
100// focusFirst gives the focus to the first control that can take it.
101func (d *Dialog) focusFirst() {
102 for i, c := range d.controls {
103 if c.CanFocus() {
104 d.SetFocus(i)
105 return
106 }
107 }
108}
109
110// moveFocus walks the ring in the given direction, skipping the controls that
111// cannot take the focus.
112func (d *Dialog) moveFocus(step int) {
113 if len(d.controls) == 0 {
114 return
115 }
116
117 index := d.focus
118 for range len(d.controls) {
119 index = (index + step + len(d.controls)) % len(d.controls)
120 if d.controls[index].CanFocus() {
121 d.SetFocus(index)
122 return
123 }
124 }
125}
126
127// defaultButton returns the button Enter presses, or nil when there is none.
128func (d *Dialog) defaultButton() *Button {
129 for _, c := range d.controls {
130 if button, ok := c.(*Button); ok && button.Default {
131 return button
132 }
133 }
134 return nil
135}
136
137// Draw paints the dialog's frame, title and controls.
138func (d *Dialog) Draw(p *Painter, th *theme.Theme) {
139 bounds := d.Bounds()
140 DrawShadow(p, bounds, th.Style(theme.KeyShadow))
141
142 panel := p.Sub(bounds)
143 panel.Clear(th.Style(theme.KeyDialogBody))
144 DrawFrame(panel, panel.Size(), FrameDouble, th.Style(theme.KeyDialogFrame))
145
146 if d.title != "" {
147 text := " " + d.title + " "
148 x := (panel.Size().W - len([]rune(text))) / 2
149 panel.Text(max(x, 1), 0, text, th.Style(theme.KeyDialogTitle))
150 }
151
152 for _, c := range d.controls {
153 c.Draw(p.Sub(bounds), th)
154 }
155}
156
157// HandleKey works the ring, then hands the key to the focused control.
158//
159// The order is: the dialog's own OnKey, the keys that drive the dialog itself,
160// the focused control, the default button, and finally the hot keys. Whatever
161// is left over is swallowed, because the dialog is modal.
162func (d *Dialog) HandleKey(ev *tcell.EventKey) bool {
163 for _, handle := range dialogKeyHandlers {
164 if handle(d, ev) {
165 return true
166 }
167 }
168 return d.handleHotKey(ev)
169}
170
171// dialogKeyHandlers are tried in order, and the first to consume the key wins.
172//
173// The order is the design: the dialog's own hook, then the keys that drive the
174// dialog itself, then the focused control, then the arrows as a focus ring,
175// then the default button. Anything left over is swallowed by handleHotKey,
176// because a dialog is modal.
177var dialogKeyHandlers = []func(*Dialog, *tcell.EventKey) bool{
178 (*Dialog).handleOwnKey,
179 (*Dialog).handleRingKey,
180 (*Dialog).focusedHandled,
181 (*Dialog).handleArrowRingKey,
182 (*Dialog).pressedDefaultButton,
183}
184
185// handleOwnKey offers the key to the hook the dialog's owner installed.
186func (d *Dialog) handleOwnKey(ev *tcell.EventKey) bool {
187 return d.OnKey != nil && d.OnKey(ev)
188}
189
190// handleRingKey deals with the keys that drive the dialog itself, whatever has
191// the focus: Escape, and Tab in both directions.
192func (d *Dialog) handleRingKey(ev *tcell.EventKey) bool {
193 switch ev.Key() {
194 case tcell.KeyEscape:
195 d.Close(ResultCancel)
196 case tcell.KeyTab:
197 d.moveFocus(+1)
198 case tcell.KeyBacktab:
199 d.moveFocus(-1)
200 default:
201 return false
202 }
203 return true
204}
205
206// handleArrowRingKey walks the ring with the up and down arrows.
207//
208// This runs *after* the focused control has declined the key, not before: a
209// list box uses the arrows to walk its own entries, and a dialog that grabbed
210// them first would leave every list in it unusable from the keyboard.
211func (d *Dialog) handleArrowRingKey(ev *tcell.EventKey) bool {
212 switch ev.Key() {
213 case tcell.KeyDown:
214 d.moveFocus(+1)
215 case tcell.KeyUp:
216 d.moveFocus(-1)
217 default:
218 return false
219 }
220 return true
221}
222
223// focusedHandled offers the key to the control that has the focus.
224func (d *Dialog) focusedHandled(ev *tcell.EventKey) bool {
225 focused := d.Focused()
226 return focused != nil && focused.HandleKey(ev)
227}
228
229// pressedDefaultButton presses the default button on Enter, from wherever the
230// focus happens to be.
231func (d *Dialog) pressedDefaultButton(ev *tcell.EventKey) bool {
232 if ev.Key() != tcell.KeyEnter {
233 return false
234 }
235 button := d.defaultButton()
236 if button == nil {
237 return false
238 }
239 button.Press()
240 return true
241}
242
243// handleHotKey presses the control whose Alt-letter was typed.
244func (d *Dialog) handleHotKey(ev *tcell.EventKey) bool {
245 if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 {
246 // A dialog is modal, so it swallows every key rather than letting it
247 // reach the editor behind.
248 return true
249 }
250
251 for i, c := range d.controls {
252 button, ok := c.(*Button)
253 if ok && MatchesHotKey(button.Label(), ev.Rune()) {
254 d.SetFocus(i)
255 button.Press()
256 return true
257 }
258 }
259 return true
260}
261
262// HandleMouse gives the click to the control it landed on, focusing it first.
263func (d *Dialog) HandleMouse(ev *tcell.EventMouse) bool {
264 for i, c := range d.controls {
265 x, y := ev.Position()
266 if !c.Bounds().Contains(x, y) {
267 continue
268 }
269 if ev.Buttons() == tcell.Button1 && c.CanFocus() {
270 d.SetFocus(i)
271 }
272 return c.HandleMouse(ev)
273 }
274 // A modal dialog swallows clicks that miss it too, so the editor behind
275 // cannot be moved while a question is waiting.
276 return true
277}
278
279// MoveTo puts the dialog's top-left corner at (x, y), taking its controls with
280// it.
281//
282// Controls are placed in screen coordinates when the dialog is built, so
283// moving the frame on its own would leave every one of them behind.
284func (d *Dialog) MoveTo(x, y int) {
285 bounds := d.Bounds()
286 dx, dy := x-bounds.X, y-bounds.Y
287 if dx == 0 && dy == 0 {
288 return
289 }
290
291 d.SetBounds(bounds.Move(dx, dy))
292 for _, control := range d.controls {
293 control.SetBounds(control.Bounds().Move(dx, dy))
294 }
295}
296
297// CenterIn puts the dialog in the middle of r, controls and all. It is what a
298// dialog needs after the terminal has been resized under it.
299func (d *Dialog) CenterIn(r Rect) {
300 centred := d.Bounds().CenteredIn(r)
301 d.MoveTo(centred.X, centred.Y)
302}
303
304// Layout is a small helper for placing controls inside a dialog: it returns
305// the rectangle at (x, y) of the given size, relative to the dialog's own
306// top-left corner.
307//
308// field.SetBounds(d.Layout(2, 3, 40, 1))
309func (d *Dialog) Layout(x, y, w, h int) Rect {
310 return Rect{X: d.Bounds().X + x, Y: d.Bounds().Y + y, W: w, H: h}
311}