| 🛟 Updated. 28d5985 k33g 21h ago | 1 | package ui |
| 2 | |
| 3 | import ( |
| 4 | "github.com/gdamore/tcell/v2" |
| 5 | |
| 6 | "codeberg.org/turbo-editors/turbo-core/theme" |
| 7 | ) |
| 8 | |
| 9 | // Result is how a dialog ended. |
| 10 | type Result int |
| 11 | |
| 12 | // The ways a dialog can end. |
| 13 | const ( |
| 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. |
| 29 | type 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)) |
| 47 | func NewDialog(title string) *Dialog { |
| 48 | return &Dialog{title: title, result: ResultNone} |
| 49 | } |
| 50 | |
| 51 | // Title returns the dialog's title. |
| 52 | func (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. |
| 56 | func (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. |
| 60 | func (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. |
| 68 | func (d *Dialog) Controls() []Focusable { return d.controls } |
| 69 | |
| 70 | // Result returns how the dialog ended, or ResultNone while it is still open. |
| 71 | func (d *Dialog) Result() Result { return d.result } |
| 72 | |
| 73 | // Done reports whether the dialog has been closed. |
| 74 | func (d *Dialog) Done() bool { return d.result != ResultNone } |
| 75 | |
| 76 | // Close ends the dialog with the given result. |
| 77 | func (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. |
| 81 | func (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. |
| 89 | func (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. |
| 101 | func (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. |
| 112 | func (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. |
| 128 | func (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. |
| 138 | func (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. |
| 162 | func (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. |
| 177 | var 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. |
| 186 | func (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. |
| 192 | func (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. |
| 211 | func (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. |
| 224 | func (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. |
| 231 | func (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. |
| 244 | func (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. |
| 263 | func (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. |
| 284 | func (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. |
| 299 | func (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)) |
| 309 | func (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 | } |