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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +8 -0
- data/examples/clock.rb +112 -0
- data/examples/counter.rb +48 -0
- data/examples/csv_viewer.rb +233 -0
- data/examples/file_browser.rb +665 -0
- data/examples/form.rb +633 -0
- data/examples/life.rb +144 -0
- data/examples/paint.rb +246 -0
- data/examples/todo.rb +250 -0
- data/examples/widgets.rb +101 -0
- data/lib/tui_tui/ansi.rb +34 -0
- data/lib/tui_tui/canvas.rb +187 -0
- data/lib/tui_tui/canvas_compositor.rb +45 -0
- data/lib/tui_tui/cell.rb +11 -0
- data/lib/tui_tui/color_depth.rb +39 -0
- data/lib/tui_tui/confirm.rb +74 -0
- data/lib/tui_tui/display_text.rb +73 -0
- data/lib/tui_tui/event.rb +10 -0
- data/lib/tui_tui/event_stream.rb +39 -0
- data/lib/tui_tui/focus_ring.rb +25 -0
- data/lib/tui_tui/fuzzy.rb +56 -0
- data/lib/tui_tui/help.rb +44 -0
- data/lib/tui_tui/key_code.rb +9 -0
- data/lib/tui_tui/key_intent.rb +29 -0
- data/lib/tui_tui/key_reader.rb +175 -0
- data/lib/tui_tui/line.rb +59 -0
- data/lib/tui_tui/list.rb +45 -0
- data/lib/tui_tui/modal.rb +30 -0
- data/lib/tui_tui/pager.rb +94 -0
- data/lib/tui_tui/palette.rb +49 -0
- data/lib/tui_tui/prompt.rb +111 -0
- data/lib/tui_tui/rect.rb +48 -0
- data/lib/tui_tui/runtime.rb +53 -0
- data/lib/tui_tui/screen.rb +85 -0
- data/lib/tui_tui/scroll_list.rb +57 -0
- data/lib/tui_tui/scrollbar.rb +40 -0
- data/lib/tui_tui/select.rb +104 -0
- data/lib/tui_tui/size.rb +5 -0
- data/lib/tui_tui/span.rb +14 -0
- data/lib/tui_tui/status_bar.rb +23 -0
- data/lib/tui_tui/style.rb +101 -0
- data/lib/tui_tui/terminal_session.rb +65 -0
- data/lib/tui_tui/terminal_size.rb +24 -0
- data/lib/tui_tui/text_sanitizer.rb +13 -0
- data/lib/tui_tui/text_view.rb +52 -0
- data/lib/tui_tui/theme.rb +127 -0
- data/lib/tui_tui/toast.rb +82 -0
- data/lib/tui_tui/version.rb +5 -0
- data/lib/tui_tui/width.rb +101 -0
- data/lib/tui_tui.rb +51 -0
- 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
|