| 🛟 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 | // Label is a line of static text in a dialog. The focus ring skips it. |
| 10 | type Label struct { |
| 11 | FocusBox |
| 12 | text string |
| 13 | } |
| 14 | |
| 15 | // NewLabel returns a label showing text. |
| 16 | func NewLabel(text string) *Label { return &Label{text: text} } |
| 17 | |
| 18 | // SetText changes what the label shows. |
| 19 | func (l *Label) SetText(text string) { l.text = text } |
| 20 | |
| 21 | // CanFocus is false: a label is decoration, so Tab walks past it. |
| 22 | func (l *Label) CanFocus() bool { return false } |
| 23 | |
| 24 | // Draw paints the text. |
| 25 | func (l *Label) Draw(p *Painter, th *theme.Theme) { |
| 26 | area := p.Sub(l.Bounds()) |
| 27 | area.TextLimited(0, 0, area.Size().W, l.text, th.Style(theme.KeyDialogLabel)) |
| 28 | } |
| 29 | |
| 30 | // Button is a push button. Its label may carry a hot key, as in "~O~K". |
| 31 | type Button struct { |
| 32 | FocusBox |
| 33 | label string |
| 34 | action func() |
| 35 | Default bool // Enter presses this one even when the focus is elsewhere |
| 36 | } |
| 37 | |
| 38 | // NewButton returns a button that runs action when pressed. |
| 39 | // |
| 40 | // ok := ui.NewButton("~O~K", func() { dialog.Close(ui.ResultOK) }) |
| 41 | // ok.Default = true |
| 42 | func NewButton(label string, action func()) *Button { |
| 43 | return &Button{label: label, action: action} |
| 44 | } |
| 45 | |
| 46 | // Label returns the button's label, markers included. |
| 47 | func (b *Button) Label() string { return b.label } |
| 48 | |
| 49 | // ButtonWidth returns how wide a button with this label needs to be, brackets |
| 50 | // and padding included. Dialogs use it to lay a row of buttons out. |
| 51 | func ButtonWidth(label string) int { return LabelWidth(label) + 4 } |
| 52 | |
| 53 | // Press runs the button's action. |
| 54 | func (b *Button) Press() { |
| 55 | if b.action != nil { |
| 56 | b.action() |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Draw paints the button, framed in brackets the way Turbo Vision drew them. |
| 61 | func (b *Button) Draw(p *Painter, th *theme.Theme) { |
| 62 | area := p.Sub(b.Bounds()) |
| 63 | |
| 64 | normal := th.Style(theme.KeyButton) |
| 65 | if b.Focused() { |
| 66 | normal = th.Style(theme.KeyButtonFocused) |
| 67 | } |
| 68 | area.Clear(normal) |
| 69 | |
| 70 | marker := " " |
| 71 | if b.Focused() || b.Default { |
| 72 | marker = "►" |
| 73 | } |
| 74 | area.Text(0, 0, marker, normal) |
| 75 | DrawLabel(area, 2, 0, b.label, normal, th.Style(theme.KeyButtonShortcut)) |
| 76 | } |
| 77 | |
| 78 | // HandleKey presses the button on Enter or Space when it has the focus. |
| 79 | func (b *Button) HandleKey(ev *tcell.EventKey) bool { |
| 80 | if !b.Focused() { |
| 81 | return false |
| 82 | } |
| 83 | if ev.Key() == tcell.KeyEnter || (ev.Key() == tcell.KeyRune && ev.Rune() == ' ') { |
| 84 | b.Press() |
| 85 | return true |
| 86 | } |
| 87 | return false |
| 88 | } |
| 89 | |
| 90 | // HandleMouse presses the button when it is clicked. |
| 91 | func (b *Button) HandleMouse(ev *tcell.EventMouse) bool { |
| 92 | if !b.hit(ev) { |
| 93 | return false |
| 94 | } |
| 95 | if ev.Buttons() == tcell.Button1 { |
| 96 | b.Press() |
| 97 | } |
| 98 | return true |
| 99 | } |
| 100 | |
| 101 | // InputLine is a single-line text field. |
| 102 | // |
| 103 | // It keeps its own cursor and horizontal scroll, so a path longer than the |
| 104 | // field stays editable; the selection Turbo Vision offered is deliberately |
| 105 | // left out, since a dialog field rarely needs it. |
| 106 | type InputLine struct { |
| 107 | FocusBox |
| 108 | text []rune |
| 109 | cursor int |
| 110 | offset int |
| 111 | |
| 112 | // OnChange is called after every edit, which is what lets a file dialog |
| 113 | // refilter its list as the user types. |
| 114 | OnChange func(string) |
| 115 | } |
| 116 | |
| 117 | // NewInputLine returns a field holding text, with the cursor at its end. |
| 118 | func NewInputLine(text string) *InputLine { |
| 119 | runes := []rune(text) |
| 120 | return &InputLine{text: runes, cursor: len(runes)} |
| 121 | } |
| 122 | |
| 123 | // Text returns what the field holds. |
| 124 | func (i *InputLine) Text() string { return string(i.text) } |
| 125 | |
| 126 | // SetText replaces the content and puts the cursor at its end. |
| 127 | func (i *InputLine) SetText(text string) { |
| 128 | i.text = []rune(text) |
| 129 | i.cursor = len(i.text) |
| 130 | i.offset = 0 |
| 131 | i.notify() |
| 132 | } |
| 133 | |
| 134 | // notify tells the owner the content changed. |
| 135 | func (i *InputLine) notify() { |
| 136 | if i.OnChange != nil { |
| 137 | i.OnChange(string(i.text)) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Draw paints the field and, when focused, puts the terminal cursor in it. |
| 142 | func (i *InputLine) Draw(p *Painter, th *theme.Theme) { |
| 143 | area := p.Sub(i.Bounds()) |
| 144 | width := area.Size().W |
| 145 | |
| 146 | style := th.Style(theme.KeyInput) |
| 147 | if i.Focused() { |
| 148 | style = th.Style(theme.KeyInputFocused) |
| 149 | } |
| 150 | area.Clear(style) |
| 151 | |
| 152 | i.scrollToCursor(width) |
| 153 | visible := i.text[min(i.offset, len(i.text)):] |
| 154 | area.Text(0, 0, string(visible), style) |
| 155 | |
| 156 | if i.Focused() { |
| 157 | area.ShowCursor(i.cursor-i.offset, 0) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // scrollToCursor slides the visible window so the cursor is inside it. |
| 162 | func (i *InputLine) scrollToCursor(width int) { |
| 163 | if width <= 0 { |
| 164 | return |
| 165 | } |
| 166 | i.offset = min(i.offset, i.cursor) |
| 167 | if i.cursor-i.offset >= width { |
| 168 | i.offset = i.cursor - width + 1 |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // HandleKey edits the field. |
| 173 | func (i *InputLine) HandleKey(ev *tcell.EventKey) bool { |
| 174 | if !i.Focused() { |
| 175 | return false |
| 176 | } |
| 177 | |
| 178 | switch ev.Key() { |
| 179 | case tcell.KeyLeft: |
| 180 | i.cursor = max(i.cursor-1, 0) |
| 181 | case tcell.KeyRight: |
| 182 | i.cursor = min(i.cursor+1, len(i.text)) |
| 183 | case tcell.KeyHome: |
| 184 | i.cursor = 0 |
| 185 | case tcell.KeyEnd: |
| 186 | i.cursor = len(i.text) |
| 187 | case tcell.KeyBackspace, tcell.KeyBackspace2: |
| 188 | i.deleteAt(i.cursor - 1) |
| 189 | case tcell.KeyDelete: |
| 190 | i.deleteAt(i.cursor) |
| 191 | case tcell.KeyCtrlU: |
| 192 | i.SetText("") |
| 193 | case tcell.KeyRune: |
| 194 | // Alt-letter belongs to the dialog's buttons, not to the text: typing |
| 195 | // it here would swallow the shortcut before it could be matched. |
| 196 | if ev.Modifiers()&(tcell.ModAlt|tcell.ModCtrl) != 0 { |
| 197 | return false |
| 198 | } |
| 199 | i.insert(ev.Rune()) |
| 200 | default: |
| 201 | return false |
| 202 | } |
| 203 | return true |
| 204 | } |
| 205 | |
| 206 | // insert puts a character in at the cursor. |
| 207 | func (i *InputLine) insert(r rune) { |
| 208 | i.text = append(i.text[:i.cursor], append([]rune{r}, i.text[i.cursor:]...)...) |
| 209 | i.cursor++ |
| 210 | i.notify() |
| 211 | } |
| 212 | |
| 213 | // deleteAt removes the character at an index, ignoring one out of range. |
| 214 | func (i *InputLine) deleteAt(index int) { |
| 215 | if index < 0 || index >= len(i.text) { |
| 216 | return |
| 217 | } |
| 218 | i.text = append(i.text[:index], i.text[index+1:]...) |
| 219 | i.cursor = index |
| 220 | i.notify() |
| 221 | } |
| 222 | |
| 223 | // HandleMouse puts the cursor where the field was clicked. |
| 224 | func (i *InputLine) HandleMouse(ev *tcell.EventMouse) bool { |
| 225 | if !i.hit(ev) { |
| 226 | return false |
| 227 | } |
| 228 | if ev.Buttons() == tcell.Button1 { |
| 229 | x, _ := ev.Position() |
| 230 | i.cursor = min(max(x-i.Bounds().X+i.offset, 0), len(i.text)) |
| 231 | } |
| 232 | return true |
| 233 | } |
| 234 | |
| 235 | // CheckBox is an on/off box with a label beside it. |
| 236 | type CheckBox struct { |
| 237 | FocusBox |
| 238 | label string |
| 239 | checked bool |
| 240 | |
| 241 | // OnToggle is called whenever the box changes state. |
| 242 | OnToggle func(bool) |
| 243 | } |
| 244 | |
| 245 | // NewCheckBox returns a box, initially in the given state. |
| 246 | func NewCheckBox(label string, checked bool) *CheckBox { |
| 247 | return &CheckBox{label: label, checked: checked} |
| 248 | } |
| 249 | |
| 250 | // Checked reports whether the box is ticked. |
| 251 | func (c *CheckBox) Checked() bool { return c.checked } |
| 252 | |
| 253 | // SetChecked sets the box's state and calls OnToggle. |
| 254 | func (c *CheckBox) SetChecked(checked bool) { |
| 255 | c.checked = checked |
| 256 | if c.OnToggle != nil { |
| 257 | c.OnToggle(checked) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // Toggle flips the box. |
| 262 | func (c *CheckBox) Toggle() { c.SetChecked(!c.checked) } |
| 263 | |
| 264 | // Draw paints the box and its label. |
| 265 | func (c *CheckBox) Draw(p *Painter, th *theme.Theme) { |
| 266 | area := p.Sub(c.Bounds()) |
| 267 | |
| 268 | style := th.Style(theme.KeyCheckbox) |
| 269 | if c.Focused() { |
| 270 | style = th.Style(theme.KeyCheckboxFocused) |
| 271 | } |
| 272 | area.Clear(style) |
| 273 | |
| 274 | mark := ' ' |
| 275 | if c.checked { |
| 276 | mark = '×' |
| 277 | } |
| 278 | area.Text(0, 0, "[", style) |
| 279 | area.SetCell(1, 0, mark, style) |
| 280 | area.Text(2, 0, "] ", style) |
| 281 | DrawLabel(area, 4, 0, c.label, style, style) |
| 282 | } |
| 283 | |
| 284 | // HandleKey toggles the box on Space or Enter. |
| 285 | func (c *CheckBox) HandleKey(ev *tcell.EventKey) bool { |
| 286 | if !c.Focused() { |
| 287 | return false |
| 288 | } |
| 289 | if ev.Key() == tcell.KeyEnter || (ev.Key() == tcell.KeyRune && ev.Rune() == ' ') { |
| 290 | c.Toggle() |
| 291 | return true |
| 292 | } |
| 293 | return false |
| 294 | } |
| 295 | |
| 296 | // HandleMouse toggles the box when it is clicked. |
| 297 | func (c *CheckBox) HandleMouse(ev *tcell.EventMouse) bool { |
| 298 | if !c.hit(ev) { |
| 299 | return false |
| 300 | } |
| 301 | if ev.Buttons() == tcell.Button1 { |
| 302 | c.Toggle() |
| 303 | } |
| 304 | return true |
| 305 | } |