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

🛟 Updated. 28d5985 · on v0.9.0 · k33g · 21h ago
dialog.go · 311 lines · 8.7 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
package ui

import (
	"github.com/gdamore/tcell/v2"

	"codeberg.org/turbo-editors/turbo-core/theme"
)

// Result is how a dialog ended.
type Result int

// The ways a dialog can end.
const (
	// ResultNone means the dialog is still open.
	ResultNone Result = iota
	ResultOK
	ResultCancel
	// ResultNo is the third answer a Yes / No / Cancel question can give:
	// neither accepting nor cancelling.
	ResultNo
)

// Dialog is a modal window holding a ring of controls.
//
// While a dialog is open it is offered every event first and nothing behind it
// sees any, which is what modal means here. Tab and Shift-Tab walk the ring,
// Escape cancels, and Enter presses the default button from wherever the focus
// happens to be.
type Dialog struct {
	Box
	title    string
	controls []Focusable
	focus    int
	result   Result

	// OnKey is offered every key before the focused control sees it, so a
	// dialog can add a shortcut of its own without subclassing anything.
	OnKey func(ev *tcell.EventKey) bool
}

// NewDialog returns an open dialog with the given title.
//
//	d := ui.NewDialog("Open a File")
//	d.Add(nameField)
//	d.Add(ui.NewButton("~O~K", func() { d.Close(ui.ResultOK) }))
//	d.SetBounds(ui.Rect{W: 50, H: 12}.CenteredIn(screen))
func NewDialog(title string) *Dialog {
	return &Dialog{title: title, result: ResultNone}
}

// Title returns the dialog's title.
func (d *Dialog) Title() string { return d.title }

// SetTitle changes the dialog's title, which a file browser uses to show which
// directory it is looking at.
func (d *Dialog) SetTitle(title string) { d.title = title }

// Add puts a control in the dialog and gives the focus to the first one that
// can take it.
func (d *Dialog) Add(control Focusable) {
	d.controls = append(d.controls, control)
	if len(d.controls) == 1 || !d.controls[d.focus].CanFocus() {
		d.focusFirst()
	}
}

// Controls returns the dialog's controls, in ring order.
func (d *Dialog) Controls() []Focusable { return d.controls }

// Result returns how the dialog ended, or ResultNone while it is still open.
func (d *Dialog) Result() Result { return d.result }

// Done reports whether the dialog has been closed.
func (d *Dialog) Done() bool { return d.result != ResultNone }

// Close ends the dialog with the given result.
func (d *Dialog) Close(result Result) { d.result = result }

// Focused returns the control the keyboard is talking to, or nil when the
// dialog holds nothing focusable.
func (d *Dialog) Focused() Focusable {
	if d.focus < 0 || d.focus >= len(d.controls) {
		return nil
	}
	return d.controls[d.focus]
}

// SetFocus gives the focus to control i, if it can take it.
func (d *Dialog) SetFocus(i int) {
	if i < 0 || i >= len(d.controls) || !d.controls[i].CanFocus() {
		return
	}
	for _, c := range d.controls {
		c.SetFocused(false)
	}
	d.focus = i
	d.controls[i].SetFocused(true)
}

// focusFirst gives the focus to the first control that can take it.
func (d *Dialog) focusFirst() {
	for i, c := range d.controls {
		if c.CanFocus() {
			d.SetFocus(i)
			return
		}
	}
}

// moveFocus walks the ring in the given direction, skipping the controls that
// cannot take the focus.
func (d *Dialog) moveFocus(step int) {
	if len(d.controls) == 0 {
		return
	}

	index := d.focus
	for range len(d.controls) {
		index = (index + step + len(d.controls)) % len(d.controls)
		if d.controls[index].CanFocus() {
			d.SetFocus(index)
			return
		}
	}
}

// defaultButton returns the button Enter presses, or nil when there is none.
func (d *Dialog) defaultButton() *Button {
	for _, c := range d.controls {
		if button, ok := c.(*Button); ok && button.Default {
			return button
		}
	}
	return nil
}

// Draw paints the dialog's frame, title and controls.
func (d *Dialog) Draw(p *Painter, th *theme.Theme) {
	bounds := d.Bounds()
	DrawShadow(p, bounds, th.Style(theme.KeyShadow))

	panel := p.Sub(bounds)
	panel.Clear(th.Style(theme.KeyDialogBody))
	DrawFrame(panel, panel.Size(), FrameDouble, th.Style(theme.KeyDialogFrame))

	if d.title != "" {
		text := " " + d.title + " "
		x := (panel.Size().W - len([]rune(text))) / 2
		panel.Text(max(x, 1), 0, text, th.Style(theme.KeyDialogTitle))
	}

	for _, c := range d.controls {
		c.Draw(p.Sub(bounds), th)
	}
}

// HandleKey works the ring, then hands the key to the focused control.
//
// The order is: the dialog's own OnKey, the keys that drive the dialog itself,
// the focused control, the default button, and finally the hot keys. Whatever
// is left over is swallowed, because the dialog is modal.
func (d *Dialog) HandleKey(ev *tcell.EventKey) bool {
	for _, handle := range dialogKeyHandlers {
		if handle(d, ev) {
			return true
		}
	}
	return d.handleHotKey(ev)
}

// dialogKeyHandlers are tried in order, and the first to consume the key wins.
//
// The order is the design: the dialog's own hook, then the keys that drive the
// dialog itself, then the focused control, then the arrows as a focus ring,
// then the default button. Anything left over is swallowed by handleHotKey,
// because a dialog is modal.
var dialogKeyHandlers = []func(*Dialog, *tcell.EventKey) bool{
	(*Dialog).handleOwnKey,
	(*Dialog).handleRingKey,
	(*Dialog).focusedHandled,
	(*Dialog).handleArrowRingKey,
	(*Dialog).pressedDefaultButton,
}

// handleOwnKey offers the key to the hook the dialog's owner installed.
func (d *Dialog) handleOwnKey(ev *tcell.EventKey) bool {
	return d.OnKey != nil && d.OnKey(ev)
}

// handleRingKey deals with the keys that drive the dialog itself, whatever has
// the focus: Escape, and Tab in both directions.
func (d *Dialog) handleRingKey(ev *tcell.EventKey) bool {
	switch ev.Key() {
	case tcell.KeyEscape:
		d.Close(ResultCancel)
	case tcell.KeyTab:
		d.moveFocus(+1)
	case tcell.KeyBacktab:
		d.moveFocus(-1)
	default:
		return false
	}
	return true
}

// handleArrowRingKey walks the ring with the up and down arrows.
//
// This runs *after* the focused control has declined the key, not before: a
// list box uses the arrows to walk its own entries, and a dialog that grabbed
// them first would leave every list in it unusable from the keyboard.
func (d *Dialog) handleArrowRingKey(ev *tcell.EventKey) bool {
	switch ev.Key() {
	case tcell.KeyDown:
		d.moveFocus(+1)
	case tcell.KeyUp:
		d.moveFocus(-1)
	default:
		return false
	}
	return true
}

// focusedHandled offers the key to the control that has the focus.
func (d *Dialog) focusedHandled(ev *tcell.EventKey) bool {
	focused := d.Focused()
	return focused != nil && focused.HandleKey(ev)
}

// pressedDefaultButton presses the default button on Enter, from wherever the
// focus happens to be.
func (d *Dialog) pressedDefaultButton(ev *tcell.EventKey) bool {
	if ev.Key() != tcell.KeyEnter {
		return false
	}
	button := d.defaultButton()
	if button == nil {
		return false
	}
	button.Press()
	return true
}

// handleHotKey presses the control whose Alt-letter was typed.
func (d *Dialog) handleHotKey(ev *tcell.EventKey) bool {
	if ev.Key() != tcell.KeyRune || ev.Modifiers()&tcell.ModAlt == 0 {
		// A dialog is modal, so it swallows every key rather than letting it
		// reach the editor behind.
		return true
	}

	for i, c := range d.controls {
		button, ok := c.(*Button)
		if ok && MatchesHotKey(button.Label(), ev.Rune()) {
			d.SetFocus(i)
			button.Press()
			return true
		}
	}
	return true
}

// HandleMouse gives the click to the control it landed on, focusing it first.
func (d *Dialog) HandleMouse(ev *tcell.EventMouse) bool {
	for i, c := range d.controls {
		x, y := ev.Position()
		if !c.Bounds().Contains(x, y) {
			continue
		}
		if ev.Buttons() == tcell.Button1 && c.CanFocus() {
			d.SetFocus(i)
		}
		return c.HandleMouse(ev)
	}
	// A modal dialog swallows clicks that miss it too, so the editor behind
	// cannot be moved while a question is waiting.
	return true
}

// MoveTo puts the dialog's top-left corner at (x, y), taking its controls with
// it.
//
// Controls are placed in screen coordinates when the dialog is built, so
// moving the frame on its own would leave every one of them behind.
func (d *Dialog) MoveTo(x, y int) {
	bounds := d.Bounds()
	dx, dy := x-bounds.X, y-bounds.Y
	if dx == 0 && dy == 0 {
		return
	}

	d.SetBounds(bounds.Move(dx, dy))
	for _, control := range d.controls {
		control.SetBounds(control.Bounds().Move(dx, dy))
	}
}

// CenterIn puts the dialog in the middle of r, controls and all. It is what a
// dialog needs after the terminal has been resized under it.
func (d *Dialog) CenterIn(r Rect) {
	centred := d.Bounds().CenteredIn(r)
	d.MoveTo(centred.X, centred.Y)
}

// Layout is a small helper for placing controls inside a dialog: it returns
// the rectangle at (x, y) of the given size, relative to the dialog's own
// top-left corner.
//
//	field.SetBounds(d.Layout(2, 3, 40, 1))
func (d *Dialog) Layout(x, y, w, h int) Rect {
	return Rect{X: d.Bounds().X + x, Y: d.Bounds().Y + y, W: w, H: h}
}