tui_tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. metadata +98 -0
data/examples/form.rb ADDED
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A focus-driven form: fields of several kinds (single-line text, multi-line
5
+ # text area, dropdown select, radio list, checkbox group, button) stacked one
6
+ # above the next, with one focused at a time. It shows how a TuiTui app composes
7
+ # small field widgets of differing (and changing) heights, lays them out by
8
+ # accumulating their rows, moves focus with a FocusRing (Tab / arrows, or a
9
+ # mouse click), edits text, and validates on submit. The text cursor is the real
10
+ # hardware cursor; markers are ASCII (N7): (*) radio, [x] checkbox, v/^ dropdown.
11
+ #
12
+ # ruby examples/form.rb
13
+ #
14
+ # Mouse: click a field — or a specific option / text position — to focus and act.
15
+ # Keys: Tab / Shift-Tab move fields, up/down move within a list or text area (and
16
+ # spill to the next/previous field at the edges), left/right & Home/End
17
+ # edit text (Ctrl-A/E line start/end, Ctrl-B/F back/forward, Ctrl-D delete,
18
+ # Ctrl-P/N prev/next line in the text area), Space select/toggle an option,
19
+ # Enter submit (a newline inside the text area), q / Ctrl-C quit.
20
+
21
+ require_relative "../lib/tui_tui"
22
+
23
+ module FormSample
24
+ LABEL = TuiTui::Style.new(attrs: [:bold])
25
+ HINT = TuiTui::Style.new(attrs: [:dim])
26
+ MARKER = TuiTui::Style.new(attrs: [:bold]) # the ">" beside the focused field
27
+ ERR = TuiTui::Style.new(fg: :bright_red)
28
+ GOOD = TuiTui::Style.new(fg: :bright_green)
29
+ # Colour-agnostic so it reads on any terminal: the input region is an
30
+ # underline and typed text keeps the default colours. The text cursor is the
31
+ # real hardware cursor (Canvas#cursor_at), so it is always legible and the IME
32
+ # candidate window anchors to the character being edited.
33
+ BOX = TuiTui::Style.new(attrs: [:underline]) # the (empty) input region
34
+ TEXT = TuiTui::Style.new # typed text, default colours
35
+ HILITE = TuiTui::Style.new(attrs: [:reverse]) # highlighted list option / button
36
+
37
+ CTRL_A = 1.chr # move to start of line (Emacs/readline bindings)
38
+ CTRL_B = 2.chr # back one character (like left)
39
+ CTRL_D = 4.chr # delete the character under the cursor (like Delete)
40
+ CTRL_E = 5.chr # move to end of line
41
+ CTRL_F = 6.chr # forward one character (like right)
42
+ CTRL_N = 14.chr # next line (like down)
43
+ CTRL_P = 16.chr # previous line (like up)
44
+
45
+ TOP = 1 # first field row
46
+ LABEL_COL = 4 # where labels (and list options) start
47
+ OPT_COL = 6 # list options are indented under their label
48
+ VALUE_COL = 18 # where value boxes start
49
+ VALUE_W = 30 # value-box width
50
+ ROLES = ["Engineer", "Designer", "Manager", "その他"].freeze
51
+ CONTACTS = ["Email", "SMS", "Push", "郵送"].freeze
52
+ COUNTRIES = ["日本", "United States", "United Kingdom", "Deutschland", "France", "中国", "한국"].freeze
53
+
54
+ # Shared text-editing helpers, so single- and multi-line fields agree on what
55
+ # is printable and how a click column maps to a character index.
56
+ module Text
57
+ module_function
58
+
59
+ # No control bytes (Enter/Tab/Esc/Backspace never insert); multibyte passes.
60
+ def printable?(string) = string.bytes.all? { |b| b >= 0x20 && b != 0x7F }
61
+
62
+ def width(chars) = TuiTui::DisplayText.new(chars.join).width
63
+
64
+ # The character index whose left edge sits closest to `rel_col` columns in —
65
+ # accounting for wide characters before it.
66
+ def column_index(chars, rel_col)
67
+ width = 0
68
+ chars.each_with_index do |ch, i|
69
+ w = TuiTui::DisplayText.new(ch).width
70
+ return i if rel_col < width + ((w + 1) / 2)
71
+
72
+ width += w
73
+ end
74
+ chars.length
75
+ end
76
+ end
77
+
78
+ # A single editable line. The cursor is a character index, drawn as a bright
79
+ # block at the right column even past wide characters.
80
+ class TextField
81
+ attr_reader :key, :label
82
+
83
+ def initialize(key, label, value: "")
84
+ @key = key
85
+ @label = label
86
+ @chars = value.grapheme_clusters # edit by grapheme, so the cursor never lands inside an emoji/combining cluster
87
+ @pos = @chars.length
88
+ end
89
+
90
+ def rows = 1
91
+ def value = @chars.join
92
+ def summary = value.empty? ? "(empty)" : value
93
+ def capturing? = true # keys are text, so "q" never quits while editing
94
+
95
+ # Returns :submit / :focus_next / :focus_prev to the form, or nil (consumed).
96
+ def handle(key)
97
+ case key
98
+ when "\r" then :submit
99
+ when :down then :focus_next
100
+ when :up then :focus_prev
101
+ when TuiTui::KeyCode::BACKSPACE, :backspace then edit { delete_back }
102
+ when :delete, CTRL_D then edit { @chars.delete_at(@pos) }
103
+ when :left, CTRL_B then edit { @pos = [@pos - 1, 0].max }
104
+ when :right, CTRL_F then edit { @pos = [@pos + 1, @chars.length].min }
105
+ when :home, CTRL_A then edit { @pos = 0 }
106
+ when :end, CTRL_E then edit { @pos = @chars.length }
107
+ when String then edit { insert(key) if Text.printable?(key) }
108
+ end
109
+ end
110
+
111
+ def click(_rel_row, col)
112
+ @pos = Text.column_index(@chars, col - VALUE_COL)
113
+ nil
114
+ end
115
+
116
+ def draw(canvas, top, focused:)
117
+ canvas.text(top, LABEL_COL, label, LABEL)
118
+ canvas.fill(TuiTui::Rect.new(row: top, col: VALUE_COL, rows: 1, cols: VALUE_W), BOX)
119
+ canvas.text(top, VALUE_COL, TuiTui::DisplayText.new(value).truncate(VALUE_W), TEXT)
120
+ canvas.cursor_at(top, VALUE_COL + Text.width(@chars[0...@pos])) if focused
121
+ end
122
+
123
+ private
124
+
125
+ def edit
126
+ yield
127
+ nil
128
+ end
129
+
130
+ # Re-cluster across the boundary so a combining mark merges into its base.
131
+ def insert(string)
132
+ head = @chars[0...@pos].join
133
+ @chars = (head + string + @chars[@pos..].join).grapheme_clusters
134
+ @pos = (head + string).grapheme_clusters.length
135
+ end
136
+
137
+ def delete_back
138
+ return if @pos.zero?
139
+
140
+ @chars.delete_at(@pos - 1)
141
+ @pos -= 1
142
+ end
143
+ end
144
+
145
+ # A multi-line text box. The buffer is an array of character arrays (one per
146
+ # line); the cursor is a (row, col) pair. Enter splits the current line,
147
+ # Backspace joins lines, arrows navigate and spill focus at the top/bottom
148
+ # edges. Only ROWS_SHOWN lines are visible; the view scrolls to track the
149
+ # cursor. Click to drop the cursor at a position.
150
+ class TextArea
151
+ attr_reader :key, :label
152
+
153
+ ROWS_SHOWN = 4
154
+
155
+ def initialize(key, label, value: "")
156
+ @key = key
157
+ @label = label
158
+ @lines = value.empty? ? [[]] : value.split("\n", -1).map(&:grapheme_clusters) # one grapheme per element
159
+ @row = @lines.size - 1
160
+ @col = @lines.last.size
161
+ @top = 0 # first visible line
162
+ scroll # keep the cursor visible even when seeded with a long value
163
+ end
164
+
165
+ def rows = ROWS_SHOWN
166
+ def value = @lines.map(&:join).join("\n")
167
+ def summary = value.empty? ? "(empty)" : "#{@lines.size} line(s), #{@lines.sum(&:size)} chars"
168
+ def capturing? = true
169
+
170
+ def handle(key)
171
+ case key
172
+ when :up, CTRL_P then @row.zero? ? :focus_prev : move(-1)
173
+ when :down, CTRL_N then @row == @lines.size - 1 ? :focus_next : move(1)
174
+ when :left, CTRL_B then edit { move_left }
175
+ when :right, CTRL_F then edit { move_right }
176
+ when :home, CTRL_A then edit { @col = 0 }
177
+ when :end, CTRL_E then edit { @col = line.size }
178
+ when "\r" then edit { split_line }
179
+ when TuiTui::KeyCode::BACKSPACE, :backspace then edit { backspace }
180
+ when :delete, CTRL_D then edit { delete_forward }
181
+ when String then edit { insert(key) if Text.printable?(key) }
182
+ end
183
+ end
184
+
185
+ def click(rel_row, col)
186
+ ln = @top + rel_row
187
+ return nil if ln >= @lines.size
188
+
189
+ @row = ln
190
+ @col = Text.column_index(@lines[ln], col - VALUE_COL)
191
+ nil
192
+ end
193
+
194
+ def draw(canvas, top, focused:)
195
+ canvas.text(top, LABEL_COL, label, LABEL)
196
+ ROWS_SHOWN.times do |i|
197
+ row = top + i
198
+ canvas.fill(TuiTui::Rect.new(row: row, col: VALUE_COL, rows: 1, cols: VALUE_W), BOX)
199
+ ln = @top + i
200
+ next if ln >= @lines.size
201
+
202
+ canvas.text(row, VALUE_COL, TuiTui::DisplayText.new(@lines[ln].join).truncate(VALUE_W), TEXT)
203
+ end
204
+ canvas.cursor_at(top + (@row - @top), VALUE_COL + Text.width(@lines[@row][0...@col])) if focused
205
+ end
206
+
207
+ private
208
+
209
+ def edit
210
+ yield
211
+ scroll
212
+ nil
213
+ end
214
+
215
+ def line = @lines[@row]
216
+
217
+ # Move the cursor `delta` rows, keeping the column within the new line.
218
+ def move(delta)
219
+ @row += delta
220
+ @col = [@col, line.size].min
221
+ scroll
222
+ nil
223
+ end
224
+
225
+ def move_left
226
+ if @col.positive? then @col -= 1
227
+ elsif @row.positive? then @row -= 1; @col = line.size
228
+ end
229
+ end
230
+
231
+ def move_right
232
+ if @col < line.size then @col += 1
233
+ elsif @row < @lines.size - 1 then @row += 1; @col = 0
234
+ end
235
+ end
236
+
237
+ def split_line
238
+ tail = line.slice!(@col..) || []
239
+ @lines.insert(@row + 1, tail)
240
+ @row += 1
241
+ @col = 0
242
+ end
243
+
244
+ def backspace
245
+ if @col.positive?
246
+ line.delete_at(@col - 1)
247
+ @col -= 1
248
+ elsif @row.positive?
249
+ prev = @lines[@row - 1]
250
+ @col = prev.size
251
+ prev.concat(line)
252
+ @lines.delete_at(@row)
253
+ @row -= 1
254
+ end
255
+ end
256
+
257
+ def delete_forward
258
+ if @col < line.size
259
+ line.delete_at(@col)
260
+ elsif @row < @lines.size - 1
261
+ line.concat(@lines.delete_at(@row + 1))
262
+ end
263
+ end
264
+
265
+ # Re-cluster the line across the boundary so combining marks merge correctly.
266
+ def insert(string)
267
+ head = line[0...@col].join
268
+ @lines[@row] = (head + string + line[@col..].join).grapheme_clusters
269
+ @col = (head + string).grapheme_clusters.length
270
+ end
271
+
272
+ # Keep the cursor line within the visible window.
273
+ def scroll
274
+ @top = @row if @row < @top
275
+ @top = @row - ROWS_SHOWN + 1 if @row >= @top + ROWS_SHOWN
276
+ end
277
+ end
278
+
279
+ # A vertical list of mutually-exclusive options — a radio group. up/down move
280
+ # a cursor (spilling at the edges); Space selects the option under it. The
281
+ # cursor (highlight) is kept separate from the selection, so moving around
282
+ # does not change the choice until you press Space.
283
+ class RadioField
284
+ attr_reader :key, :label
285
+
286
+ def initialize(key, label, options, index: 0)
287
+ @key = key
288
+ @label = label
289
+ @options = options
290
+ @index = index # the selected option
291
+ @cursor = index # the highlighted option
292
+ end
293
+
294
+ def rows = 1 + @options.size
295
+ def value = @options[@index]
296
+ def summary = value
297
+
298
+ def handle(key)
299
+ case key
300
+ when "\r" then :submit
301
+ when " " then @index = @cursor; nil
302
+ when :up then @cursor.zero? ? :focus_prev : (@cursor -= 1) && nil
303
+ when :down then @cursor == @options.size - 1 ? :focus_next : (@cursor += 1) && nil
304
+ end
305
+ end
306
+
307
+ def click(rel_row, _col)
308
+ i = rel_row - 1 # row 0 is the label
309
+ return nil unless i.between?(0, @options.size - 1)
310
+
311
+ @cursor = i
312
+ @index = i # a click moves the cursor and selects in one go
313
+ nil
314
+ end
315
+
316
+ def draw(canvas, top, focused:)
317
+ canvas.text(top, LABEL_COL, label, LABEL)
318
+ @options.each_with_index do |opt, i|
319
+ mark = i == @index ? "(*)" : "( )"
320
+ style = focused && i == @cursor ? HILITE : (i == @index ? LABEL : HINT)
321
+ canvas.text(top + 1 + i, OPT_COL, "#{mark} #{opt}", style)
322
+ end
323
+ end
324
+ end
325
+
326
+ # A vertical list of independent on/off options — a checkbox group. up/down
327
+ # move a cursor (spilling at the edges); Space toggles the option under it.
328
+ class CheckGroupField
329
+ attr_reader :key, :label
330
+
331
+ def initialize(key, label, options)
332
+ @key = key
333
+ @label = label
334
+ @options = options
335
+ @checked = Array.new(options.size, false)
336
+ @cursor = 0
337
+ end
338
+
339
+ def rows = 1 + @options.size
340
+ def value = @options.each_index.select { |i| @checked[i] }.map { |i| @options[i] }
341
+ def summary = value.empty? ? "(none)" : value.join(", ")
342
+
343
+ def handle(key)
344
+ case key
345
+ when "\r" then :submit
346
+ when " " then toggle(@cursor)
347
+ when :up then @cursor.zero? ? :focus_prev : (@cursor -= 1) && nil
348
+ when :down then @cursor == @options.size - 1 ? :focus_next : (@cursor += 1) && nil
349
+ end
350
+ end
351
+
352
+ def click(rel_row, _col)
353
+ i = rel_row - 1
354
+ return nil unless i.between?(0, @options.size - 1)
355
+
356
+ @cursor = i
357
+ toggle(i)
358
+ end
359
+
360
+ def draw(canvas, top, focused:)
361
+ canvas.text(top, LABEL_COL, label, LABEL)
362
+ @options.each_with_index do |opt, i|
363
+ mark = @checked[i] ? "[x]" : "[ ]"
364
+ style = focused && i == @cursor ? HILITE : (@checked[i] ? LABEL : HINT)
365
+ canvas.text(top + 1 + i, OPT_COL, "#{mark} #{opt}", style)
366
+ end
367
+ end
368
+
369
+ private
370
+
371
+ def toggle(i)
372
+ @checked[i] = !@checked[i]
373
+ nil
374
+ end
375
+ end
376
+
377
+ # A dropdown / combo box. Collapsed it shows just the selected value; Space (or
378
+ # a click) opens it into a candidate list, up/down move a cursor, Space/Enter
379
+ # picks (and closes), Escape cancels. While open it grows by `rows` so the form
380
+ # lays the candidates out below it; the form closes it when focus moves away.
381
+ class SelectField
382
+ attr_reader :key, :label
383
+
384
+ def initialize(key, label, options, index: 0)
385
+ @key = key
386
+ @label = label
387
+ @options = options
388
+ @index = index # the selected option
389
+ @cursor = index # the highlighted option while open
390
+ @open = false
391
+ end
392
+
393
+ def rows = @open ? 1 + @options.size : 1
394
+ def value = @options[@index]
395
+ def summary = value
396
+ def capturing? = @open # while open, keys (incl. "q") edit the list, not the app
397
+ def close = @open = false
398
+
399
+ def handle(key)
400
+ return handle_open(key) if @open
401
+
402
+ case key
403
+ when " " then open
404
+ when "\r" then :submit
405
+ when :up then :focus_prev
406
+ when :down then :focus_next
407
+ end
408
+ end
409
+
410
+ def click(rel_row, _col)
411
+ if !@open then open
412
+ elsif rel_row.zero? then close # clicking the header again closes it
413
+ else choose(rel_row - 1)
414
+ end
415
+ nil
416
+ end
417
+
418
+ def draw(canvas, top, focused:)
419
+ canvas.text(top, LABEL_COL, label, LABEL)
420
+ style = focused ? HILITE : BOX
421
+ canvas.fill(TuiTui::Rect.new(row: top, col: VALUE_COL, rows: 1, cols: VALUE_W), style)
422
+ canvas.text(top, VALUE_COL, TuiTui::DisplayText.new(value).truncate(VALUE_W - 2), style)
423
+ canvas.text(top, VALUE_COL + VALUE_W - 1, @open ? "^" : "v", style)
424
+ draw_options(canvas, top) if @open
425
+ end
426
+
427
+ private
428
+
429
+ def open
430
+ @open = true
431
+ @cursor = @index
432
+ nil
433
+ end
434
+
435
+ def handle_open(key)
436
+ case key
437
+ when :up then @cursor = [@cursor - 1, 0].max; nil
438
+ when :down then @cursor = [@cursor + 1, @options.size - 1].min; nil
439
+ when " ", "\r" then choose(@cursor); nil
440
+ when :escape then close; nil # cancel: keep the current selection
441
+ end
442
+ end
443
+
444
+ def choose(i)
445
+ @index = i if i.between?(0, @options.size - 1)
446
+ close
447
+ end
448
+
449
+ def draw_options(canvas, top)
450
+ @options.each_with_index do |opt, i|
451
+ mark = i == @index ? "*" : " "
452
+ style = i == @cursor ? HILITE : HINT
453
+ canvas.text(top + 1 + i, OPT_COL, "#{mark} #{opt}", style)
454
+ end
455
+ end
456
+ end
457
+
458
+ # The submit button: Enter / Space (or a click) submits the whole form.
459
+ class Button
460
+ attr_reader :key, :label
461
+
462
+ def initialize(key, label)
463
+ @key = key
464
+ @label = label
465
+ end
466
+
467
+ def rows = 1
468
+ def summary = nil
469
+
470
+ def handle(key)
471
+ case key
472
+ when " ", "\r" then :submit
473
+ when :up then :focus_prev
474
+ when :down then :focus_next
475
+ end
476
+ end
477
+
478
+ def click(_rel_row, _col) = :submit
479
+
480
+ def draw(canvas, top, focused:)
481
+ canvas.text(top, LABEL_COL, " #{label} ", focused ? HILITE : TEXT)
482
+ end
483
+ end
484
+
485
+ class Form
486
+ def initialize
487
+ @fields = [
488
+ TextField.new(:name, "Name"),
489
+ TextField.new(:email, "Email"),
490
+ TextArea.new(:bio, "Bio"),
491
+ SelectField.new(:country, "Country", COUNTRIES),
492
+ RadioField.new(:role, "Role", ROLES),
493
+ CheckGroupField.new(:contact, "Contact via", CONTACTS),
494
+ Button.new(:submit, "[ Submit ]"),
495
+ ]
496
+ @focus = TuiTui::FocusRing.new(@fields.map(&:key))
497
+ @errors = {}
498
+ @done = nil # the success summary once submitted
499
+ end
500
+
501
+ def update(event)
502
+ case event
503
+ when TuiTui::KeyEvent then handle_key(event.key)
504
+ when TuiTui::MouseEvent then handle_mouse(event)
505
+ else self
506
+ end
507
+ end
508
+
509
+ def view(size)
510
+ canvas = TuiTui::Canvas.blank(size)
511
+ layout.each { |field, top| draw_field(canvas, field, top) }
512
+ draw_footer(canvas, size)
513
+ canvas
514
+ end
515
+
516
+ private
517
+
518
+ def handle_key(key)
519
+ return :quit if key == TuiTui::KeyCode::CTRL_C
520
+ return :quit if key == "q" && !capturing? # "q" is a normal character while a field captures keys
521
+
522
+ case key
523
+ when "\t" then refocus(@focus.next)
524
+ when :backtab then refocus(focus_prev)
525
+ else act(focused_field.handle(key))
526
+ end
527
+ self
528
+ end
529
+
530
+ def handle_mouse(event)
531
+ return self unless event.action == :press
532
+
533
+ hit = layout.find { |field, top| event.row.between?(top, top + field.rows - 1) }
534
+ return self unless hit
535
+
536
+ field, top = hit
537
+ refocus(@focus.focus(field.key))
538
+ act(field.click(event.row - top, event.col))
539
+ self
540
+ end
541
+
542
+ # Interpret a field's reply: submit, or hand focus to a neighbour.
543
+ def act(result)
544
+ case result
545
+ when :submit then submit
546
+ when :focus_next then refocus(@focus.next)
547
+ when :focus_prev then refocus(focus_prev)
548
+ end
549
+ end
550
+
551
+ # Move focus, closing any open dropdown we are leaving (a click that keeps
552
+ # focus on the same field is left alone, so it can act on its own list).
553
+ def refocus(ring)
554
+ if ring.current != @focus.current
555
+ f = focused_field
556
+ f.close if f.respond_to?(:close)
557
+ end
558
+ @focus = ring
559
+ end
560
+
561
+ def submit
562
+ @errors = validate
563
+ @done = @errors.empty? ? summarize : nil
564
+ refocus(@focus.focus(first_invalid)) if first_invalid
565
+ end
566
+
567
+ def validate
568
+ errors = {}
569
+ errors[:name] = "required" if field(:name).value.strip.empty?
570
+ email = field(:email).value.strip
571
+ errors[:email] = "must look like a@b.c" unless email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
572
+ errors
573
+ end
574
+
575
+ def summarize
576
+ "Welcome, #{field(:name).value} <#{field(:email).value}> from #{field(:country).value} — " \
577
+ "#{field(:role).value}; contact via #{field(:contact).summary}; bio #{field(:bio).summary}"
578
+ end
579
+
580
+ # ---- focus / layout helpers ----
581
+
582
+ def focused_field = @fields.find { |f| @focus.focused?(f.key) }
583
+ def field(key) = @fields.find { |f| f.key == key }
584
+ def first_invalid = @errors.keys.first
585
+
586
+ # Whether the focused field is consuming keys (a text field, or an open
587
+ # dropdown) — so a bare "q" edits rather than quitting the app.
588
+ def capturing? = focused_field.respond_to?(:capturing?) && focused_field.capturing?
589
+
590
+ # Each field paired with its top row, stacked with a blank line between.
591
+ def layout
592
+ row = TOP
593
+ @fields.map do |field|
594
+ pair = [field, row]
595
+ row += field.rows + 1
596
+ pair
597
+ end
598
+ end
599
+
600
+ # FocusRing only walks forward; step all the way round for Shift-Tab.
601
+ def focus_prev
602
+ ring = @focus
603
+ (@fields.size - 1).times { ring = ring.next }
604
+ ring
605
+ end
606
+
607
+ # ---- drawing ----
608
+
609
+ def draw_field(canvas, field, top)
610
+ focused = @focus.focused?(field.key)
611
+ canvas.text(top, LABEL_COL - 2, ">", MARKER) if focused
612
+ field.draw(canvas, top, focused: focused)
613
+ err = @errors[field.key]
614
+ canvas.text(top, VALUE_COL + VALUE_W + 2, "<- #{err}", ERR) if err
615
+ end
616
+
617
+ def draw_footer(canvas, size)
618
+ _, last_top = layout.last
619
+ row = last_top + 2
620
+ if @done
621
+ canvas.text(row, LABEL_COL, TuiTui::DisplayText.new(@done).truncate(size.cols - LABEL_COL), GOOD)
622
+ elsif !@errors.empty?
623
+ canvas.text(row, LABEL_COL, "Please fix the highlighted fields.", ERR)
624
+ end
625
+ hint = "Tab move up/down within field Space toggle Enter submit (newline in Bio) q quit"
626
+ canvas.text(size.rows - 1, LABEL_COL, TuiTui::DisplayText.new(hint).truncate(size.cols - LABEL_COL), HINT)
627
+ end
628
+ end
629
+ end
630
+
631
+ if $PROGRAM_NAME == __FILE__
632
+ TuiTui::Runtime.new(FormSample::Form.new).run
633
+ end