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} }