tui_tui 0.2.0 → 0.4.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +51 -14
  4. data/examples/README.md +28 -0
  5. data/examples/breakout.rb +225 -0
  6. data/examples/counter.rb +11 -12
  7. data/examples/csv_viewer.rb +28 -50
  8. data/examples/downloads.rb +128 -0
  9. data/examples/file_browser.rb +106 -56
  10. data/examples/form.rb +80 -102
  11. data/examples/life.rb +1 -1
  12. data/examples/log_viewer.rb +118 -0
  13. data/examples/todo.rb +43 -64
  14. data/examples/widgets.rb +4 -4
  15. data/lib/tui_tui/box_chrome.rb +0 -9
  16. data/lib/tui_tui/box_chrome_resolver.rb +21 -0
  17. data/lib/tui_tui/box_prober.rb +3 -4
  18. data/lib/tui_tui/canvas.rb +6 -55
  19. data/lib/tui_tui/canvas_compositor.rb +12 -10
  20. data/lib/tui_tui/canvas_renderer.rb +59 -0
  21. data/lib/tui_tui/cell.rb +4 -3
  22. data/lib/tui_tui/clock.rb +11 -0
  23. data/lib/tui_tui/command.rb +21 -0
  24. data/lib/tui_tui/event_stream.rb +42 -7
  25. data/lib/tui_tui/key_code.rb +3 -0
  26. data/lib/tui_tui/key_reader.rb +1 -1
  27. data/lib/tui_tui/line.rb +11 -0
  28. data/lib/tui_tui/modal/command_palette.rb +193 -0
  29. data/lib/tui_tui/modal/confirm.rb +74 -0
  30. data/lib/tui_tui/modal/help.rb +44 -0
  31. data/lib/tui_tui/modal/pager.rb +106 -0
  32. data/lib/tui_tui/modal/prompt.rb +66 -0
  33. data/lib/tui_tui/modal/select.rb +103 -0
  34. data/lib/tui_tui/modal.rb +4 -0
  35. data/lib/tui_tui/modal_host.rb +2 -2
  36. data/lib/tui_tui/rect.rb +69 -0
  37. data/lib/tui_tui/runtime.rb +52 -20
  38. data/lib/tui_tui/screen.rb +15 -3
  39. data/lib/tui_tui/terminal_session.rb +16 -1
  40. data/lib/tui_tui/test_runtime.rb +71 -0
  41. data/lib/tui_tui/text_input.rb +168 -0
  42. data/lib/tui_tui/text_sanitizer.rb +3 -2
  43. data/lib/tui_tui/theme.rb +25 -27
  44. data/lib/tui_tui/version.rb +1 -1
  45. data/lib/tui_tui/widget/list.rb +59 -0
  46. data/lib/tui_tui/widget/progress.rb +33 -0
  47. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  48. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  49. data/lib/tui_tui/widget/spinner.rb +37 -0
  50. data/lib/tui_tui/widget/status_bar.rb +25 -0
  51. data/lib/tui_tui/widget/table.rb +81 -0
  52. data/lib/tui_tui/widget/tabs.rb +67 -0
  53. data/lib/tui_tui/widget/text_view.rb +47 -0
  54. data/lib/tui_tui/widget/toast.rb +83 -0
  55. data/lib/tui_tui.rb +24 -14
  56. metadata +28 -13
  57. data/lib/tui_tui/confirm.rb +0 -74
  58. data/lib/tui_tui/help.rb +0 -44
  59. data/lib/tui_tui/list.rb +0 -59
  60. data/lib/tui_tui/pager.rb +0 -94
  61. data/lib/tui_tui/prompt.rb +0 -111
  62. data/lib/tui_tui/scroll_list.rb +0 -57
  63. data/lib/tui_tui/scrollbar.rb +0 -41
  64. data/lib/tui_tui/select.rb +0 -104
  65. data/lib/tui_tui/status_bar.rb +0 -23
  66. data/lib/tui_tui/text_view.rb +0 -56
  67. data/lib/tui_tui/toast.rb +0 -82
  68. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
data/examples/form.rb CHANGED
@@ -51,73 +51,55 @@ module FormSample
51
51
  CONTACTS = ["Email", "SMS", "Push", "郵送"].freeze
52
52
  COUNTRIES = ["日本", "United States", "United Kingdom", "Deutschland", "France", "中国", "한국"].freeze
53
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.
54
+ # A single editable line backed by the shared TuiTui::TextInput model: it owns
55
+ # the grapheme buffer, cursor, and column geometry, so this field only adds the
56
+ # Emacs key aliases, the form's focus/submit semantics, and the chrome. A value
57
+ # wider than the box scrolls horizontally (via TextInput#viewport) to keep the
58
+ # cursor — the real hardware cursor — visible.
80
59
  class TextField
81
60
  attr_reader :key, :label
82
61
 
83
62
  def initialize(key, label, value: "")
84
63
  @key = key
85
64
  @label = label
86
- @chars = value.grapheme_clusters # edit by grapheme, so the cursor never lands inside an emoji/combining cluster
87
- @pos = @chars.length
65
+ @input = TuiTui::TextInput.new(value: value)
66
+ @scroll = 0 # left grapheme of the visible window; refreshed each draw
88
67
  end
89
68
 
90
69
  def rows = 1
91
- def value = @chars.join
70
+ def value = @input.value
92
71
  def summary = value.empty? ? "(empty)" : value
93
72
  def capturing? = true # keys are text, so "q" never quits while editing
94
73
 
95
74
  # Returns :submit / :focus_next / :focus_prev to the form, or nil (consumed).
75
+ # The canonical editing keys (text, Backspace/Delete, arrows, Home/End) go to
76
+ # TextInput; only the Emacs aliases and focus keys are handled here.
96
77
  def handle(key)
97
78
  case key
98
79
  when "\r" then :submit
99
80
  when :down then :focus_next
100
81
  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) }
82
+ when CTRL_A then edit { @input.to_home }
83
+ when CTRL_E then edit { @input.to_end }
84
+ when CTRL_B then edit { @input.left }
85
+ when CTRL_F then edit { @input.right }
86
+ when CTRL_D then edit { @input.delete_forward }
87
+ else edit { @input.handle_editing(key) }
108
88
  end
109
89
  end
110
90
 
111
91
  def click(_rel_row, col)
112
- @pos = Text.column_index(@chars, col - VALUE_COL)
92
+ @input.cursor = @input.index_at(col - VALUE_COL, from: @scroll)
113
93
  nil
114
94
  end
115
95
 
116
96
  def draw(canvas, top, focused:)
117
97
  canvas.text(top, LABEL_COL, label, LABEL)
118
98
  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
99
+ viewport = @input.viewport(VALUE_W)
100
+ @scroll = viewport.start
101
+ canvas.text(top, VALUE_COL, viewport.text, TEXT)
102
+ canvas.cursor_at(top, VALUE_COL + viewport.cursor_col) if focused
121
103
  end
122
104
 
123
105
  private
@@ -126,27 +108,14 @@ module FormSample
126
108
  yield
127
109
  nil
128
110
  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
111
  end
144
112
 
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.
113
+ # A multi-line text box built on TuiTui::TextInput: each line is a plain string
114
+ # in @lines, and the *focused* line is hydrated into a TextInput (@input) that
115
+ # owns the within-line editing, cursor, click mapping, and horizontal scroll.
116
+ # This class adds only the cross-line structure splitting on Enter, joining on
117
+ # Backspace/Delete, vertical scrolling, and focus spill at the top/bottom edges.
118
+ # Leaving a line writes it back to @lines and re-hydrates the new focused line.
150
119
  class TextArea
151
120
  attr_reader :key, :label
152
121
 
@@ -155,16 +124,17 @@ module FormSample
155
124
  def initialize(key, label, value: "")
156
125
  @key = key
157
126
  @label = label
158
- @lines = value.empty? ? [[]] : value.split("\n", -1).map(&:grapheme_clusters) # one grapheme per element
127
+ @lines = value.empty? ? [""] : value.split("\n", -1)
159
128
  @row = @lines.size - 1
160
- @col = @lines.last.size
129
+ @input = TuiTui::TextInput.new(value: @lines[@row]) # cursor at end of the last line
161
130
  @top = 0 # first visible line
131
+ @scroll = 0 # left grapheme of the focused line's viewport; refreshed each draw
162
132
  scroll # keep the cursor visible even when seeded with a long value
163
133
  end
164
134
 
165
135
  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"
136
+ def value = @lines.join("\n")
137
+ def summary = value.empty? ? "(empty)" : "#{@lines.size} line(s), #{char_count} chars"
168
138
  def capturing? = true
169
139
 
170
140
  def handle(key)
@@ -173,12 +143,12 @@ module FormSample
173
143
  when :down, CTRL_N then @row == @lines.size - 1 ? :focus_next : move(1)
174
144
  when :left, CTRL_B then edit { move_left }
175
145
  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 }
146
+ when :home, CTRL_A then edit { @input.to_home }
147
+ when :end, CTRL_E then edit { @input.to_end }
178
148
  when "\r" then edit { split_line }
179
149
  when TuiTui::KeyCode::BACKSPACE, :backspace then edit { backspace }
180
150
  when :delete, CTRL_D then edit { delete_forward }
181
- when String then edit { insert(key) if Text.printable?(key) }
151
+ when String then edit { @input.type(key) }
182
152
  end
183
153
  end
184
154
 
@@ -186,87 +156,95 @@ module FormSample
186
156
  ln = @top + rel_row
187
157
  return nil if ln >= @lines.size
188
158
 
189
- @row = ln
190
- @col = Text.column_index(@lines[ln], col - VALUE_COL)
159
+ focus(ln, 0) unless ln == @row
160
+ from = ln == @row ? @scroll : 0
161
+ @input.cursor = @input.index_at(col - VALUE_COL, from: from)
191
162
  nil
192
163
  end
193
164
 
194
165
  def draw(canvas, top, focused:)
195
166
  canvas.text(top, LABEL_COL, label, LABEL)
167
+ viewport = @input.viewport(VALUE_W)
168
+ @scroll = viewport.start
196
169
  ROWS_SHOWN.times do |i|
197
170
  row = top + i
198
171
  canvas.fill(TuiTui::Rect.new(row: row, col: VALUE_COL, rows: 1, cols: VALUE_W), BOX)
199
172
  ln = @top + i
200
173
  next if ln >= @lines.size
201
174
 
202
- canvas.text(row, VALUE_COL, TuiTui::DisplayText.new(@lines[ln].join).truncate(VALUE_W), TEXT)
175
+ text = ln == @row ? viewport.text : TuiTui::DisplayText.new(@lines[ln]).truncate(VALUE_W)
176
+ canvas.text(row, VALUE_COL, text, TEXT)
203
177
  end
204
- canvas.cursor_at(top + (@row - @top), VALUE_COL + Text.width(@lines[@row][0...@col])) if focused
178
+ canvas.cursor_at(top + (@row - @top), VALUE_COL + viewport.cursor_col) if focused
205
179
  end
206
180
 
207
181
  private
208
182
 
183
+ def char_count = @lines.sum { |line| line.grapheme_clusters.size }
184
+
185
+ # Re-hydrate `row` as the active editor with its cursor at grapheme `cursor`
186
+ # (clamped to the line by TextInput#cursor=). The previously focused line is
187
+ # already current in @lines, since #edit writes it back after every key.
188
+ def focus(row, cursor)
189
+ @row = row
190
+ @input = TuiTui::TextInput.new(value: @lines[row])
191
+ @input.cursor = cursor
192
+ end
193
+
209
194
  def edit
210
195
  yield
196
+ @lines[@row] = @input.value # keep @lines the source of truth for value/draw
211
197
  scroll
212
198
  nil
213
199
  end
214
200
 
215
- def line = @lines[@row]
216
-
217
201
  # Move the cursor `delta` rows, keeping the column within the new line.
218
202
  def move(delta)
219
- @row += delta
220
- @col = [@col, line.size].min
203
+ focus(@row + delta, @input.cursor)
221
204
  scroll
222
205
  nil
223
206
  end
224
207
 
225
208
  def move_left
226
- if @col.positive? then @col -= 1
227
- elsif @row.positive? then @row -= 1; @col = line.size
209
+ if @input.cursor.positive? then @input.left
210
+ elsif @row.positive? then focus(@row - 1, @lines[@row - 1].grapheme_clusters.size)
228
211
  end
229
212
  end
230
213
 
231
214
  def move_right
232
- if @col < line.size then @col += 1
233
- elsif @row < @lines.size - 1 then @row += 1; @col = 0
215
+ if @input.cursor < @input.length then @input.right
216
+ elsif @row < @lines.size - 1 then focus(@row + 1, 0)
234
217
  end
235
218
  end
236
219
 
220
+ # Split the focused line at the cursor; the tail becomes a new line below.
237
221
  def split_line
238
- tail = line.slice!(@col..) || []
239
- @lines.insert(@row + 1, tail)
240
- @row += 1
241
- @col = 0
222
+ graphemes = @input.value.grapheme_clusters
223
+ @lines[@row] = graphemes[0...@input.cursor].join
224
+ @lines.insert(@row + 1, graphemes[@input.cursor..].join)
225
+ focus(@row + 1, 0)
242
226
  end
243
227
 
228
+ # Delete before the cursor, joining onto the end of the previous line.
244
229
  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
230
+ return @input.delete_back if @input.cursor.positive?
231
+ return if @row.zero?
232
+
233
+ seam = @lines[@row - 1].grapheme_clusters.size
234
+ @lines[@row - 1] += @input.value
235
+ @lines.delete_at(@row)
236
+ focus(@row - 1, seam)
255
237
  end
256
238
 
239
+ # Delete under the cursor, pulling the next line up onto this one.
257
240
  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
241
+ return @input.delete_forward if @input.cursor < @input.length
242
+ return if @row == @lines.size - 1
264
243
 
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
244
+ seam = @input.cursor
245
+ @lines[@row] = @input.value + @lines[@row + 1]
246
+ @lines.delete_at(@row + 1)
247
+ focus(@row, seam)
270
248
  end
271
249
 
272
250
  # Keep the cursor line within the visible window.
data/examples/life.rb CHANGED
@@ -134,7 +134,7 @@ module LifeSample
134
134
  text = " gen #{@gen} (#{@alive.size} alive, #{state}, #{@speed}x) " \
135
135
  "click=draw Space=pause s=step +/-=speed r=reseed q=quit"
136
136
  rect = TuiTui::Rect.new(row: size.rows, col: 1, rows: 1, cols: size.cols)
137
- TuiTui::StatusBar.draw(canvas, rect, left: text, style: BAR)
137
+ TuiTui::Widget::StatusBar.draw(canvas, rect, left: text, style: BAR)
138
138
  end
139
139
  end
140
140
  end
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A tail -f style log viewer: three log sources on a Widget::Tabs bar, the
5
+ # active source in a Widget::TextView (severity-colored styled lines), and a
6
+ # Widget::StatusBar footer. Every tick appends a line to each source; while a
7
+ # tab sits at the bottom it follows new lines, scrolling up pauses following
8
+ # (G resumes) — each tab keeps its own scroll position.
9
+ #
10
+ # ruby examples/log_viewer.rb
11
+ #
12
+ # Keys: Tab / → next tab, ← previous, 1-3 jump, click a tab, j/k scroll,
13
+ # g top (pauses), G bottom (resumes following), q (or Ctrl-C) quit.
14
+
15
+ require_relative "../lib/tui_tui"
16
+
17
+ module LogViewerSample
18
+ THEME = TuiTui::Theme.auto
19
+ SOURCES = ["app", "worker", "アクセスログ"].freeze
20
+ # Weighted so most lines are fine, some warn, a few fail — like real logs.
21
+ KINDS = %i[success success success success warning danger].freeze
22
+ MESSAGES = {
23
+ success: ["GET /api/items 200 12ms", "job finished in 0.4s", "healthcheck ok", "cache hit user:42"],
24
+ warning: ["slow query 830ms", "retrying connection (2/5)", "queue depth above 100", "deprecated param 'page'"],
25
+ danger: ["POST /orders 500 Internal Server Error", "job failed: timeout", "redis connection refused"]
26
+ }.freeze
27
+
28
+ class App
29
+ def initialize
30
+ @active = 0
31
+ @clock = 0 # fake seconds, so timestamps advance deterministically
32
+ @logs = SOURCES.map { Array.new(10) { next_line } }
33
+ @tops = Array.new(SOURCES.size, 0)
34
+ @follow = Array.new(SOURCES.size, true)
35
+ end
36
+
37
+ def wants_tick? = true # always tailing
38
+
39
+ def update(event)
40
+ case event
41
+ in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then :quit
42
+ in TuiTui::KeyEvent(key: "\t" | :right) then switch(@active + 1)
43
+ in TuiTui::KeyEvent(key: :left) then switch(@active - 1)
44
+ in TuiTui::KeyEvent(key: "1" | "2" | "3") then switch(event.key.to_i - 1)
45
+ in TuiTui::KeyEvent(key: "j" | :down) then scroll(1)
46
+ in TuiTui::KeyEvent(key: "k" | :up) then scroll(-1)
47
+ in TuiTui::KeyEvent(key: "g") then scroll(-@logs[@active].size)
48
+ in TuiTui::KeyEvent(key: "G") then resume_follow
49
+ in TuiTui::MouseEvent(action: :press) then click(event)
50
+ in TuiTui::TickEvent then append
51
+ else self
52
+ end
53
+ end
54
+
55
+ def view(size)
56
+ canvas = TuiTui::Canvas.blank(size)
57
+ # Remember the rects for hit-testing and scrolling (the tabs.rb @bar
58
+ # pattern): the runtime renders before every event, so they are current.
59
+ @bar, body, status = TuiTui::Rect.of(size).split_rows(1, :rest, 1)
60
+ @page = [body.rows, 1].max
61
+ TuiTui::Widget::Tabs.draw(canvas, @bar, SOURCES, active: @active, theme: THEME)
62
+ lines = @logs[@active]
63
+ max_top = [lines.size - body.rows, 0].max
64
+ @tops[@active] = @follow[@active] ? max_top : @tops[@active].clamp(0, max_top)
65
+ TuiTui::Widget::TextView.draw(canvas, body, lines, top: @tops[@active], scrollbar: THEME)
66
+ state = @follow[@active] ? "following" : "paused"
67
+ TuiTui::Widget::StatusBar.draw(canvas, status, left: " #{SOURCES[@active]} #{lines.size} lines",
68
+ right: "#{state} Tab/1-3 switch j/k/g/G scroll q quit ",
69
+ style: THEME.bar)
70
+ canvas
71
+ end
72
+
73
+ private
74
+
75
+ def switch(index)
76
+ @active = index % SOURCES.size
77
+ self
78
+ end
79
+
80
+ def click(event)
81
+ index = @bar && TuiTui::Widget::Tabs.index_at(@bar, event, SOURCES)
82
+ index ? switch(index) : self
83
+ end
84
+
85
+ # Move the active tab's window; landing on the last page means "at the
86
+ # bottom", which turns following back on (scrolling up anywhere pauses it).
87
+ def scroll(delta)
88
+ max_top = [@logs[@active].size - (@page || 1), 0].max
89
+ @tops[@active] = (@tops[@active] + delta).clamp(0, max_top)
90
+ @follow[@active] = @tops[@active] == max_top && delta.positive?
91
+ self
92
+ end
93
+
94
+ def resume_follow
95
+ @follow[@active] = true
96
+ self
97
+ end
98
+
99
+ def append
100
+ @clock += 1
101
+ @logs.each { |log| log << next_line }
102
+ self
103
+ end
104
+
105
+ # One styled log line: a muted timestamp plus a severity-colored message
106
+ # (theme.success / warning / danger via Theme#status).
107
+ def next_line
108
+ @clock += 1
109
+ kind = KINDS.sample
110
+ stamp = format("12:%02d:%02d ", (@clock / 60) % 60, @clock % 60)
111
+ TuiTui::Line[TuiTui::Span[stamp, THEME.muted], TuiTui::Span[MESSAGES[kind].sample, THEME.status(kind)]]
112
+ end
113
+ end
114
+ end
115
+
116
+ if $PROGRAM_NAME == __FILE__
117
+ TuiTui::Runtime.new(LogViewerSample::App.new).run
118
+ end
data/examples/todo.rb CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  # A small but usable todo list. It demonstrates a typical TuiTui app shape:
5
5
  # a ScrollList-backed main view, styled rows built from Line/Span, modal widgets
6
- # for add/edit/delete/help, and width-safe rendering for Japanese text.
6
+ # for add/edit/delete/help opened and dispatched via TuiTui::ModalHost, and
7
+ # width-safe rendering for Japanese text.
8
+ # All colors come from TuiTui::Theme.auto, so it adapts to light/dark terminals.
7
9
  #
8
10
  # ruby examples/todo.rb
9
11
  #
@@ -15,19 +17,6 @@ require_relative "../lib/tui_tui"
15
17
  module TodoSample
16
18
  Todo = Data.define(:title, :done)
17
19
 
18
- S = TuiTui::Style
19
- STYLE = {
20
- title: S.new(attrs: [:bold]),
21
- dim: S.new(attrs: [:dim]),
22
- done: S.new(fg: :green),
23
- pending: S.new(fg: :yellow),
24
- select: S.new(attrs: [:reverse]),
25
- select_pending: S.new(fg: :yellow, attrs: [:reverse]),
26
- select_done: S.new(fg: :green, attrs: [:reverse]),
27
- empty: S.new(fg: :bright_black, attrs: [:italic]),
28
- filter: S.new(fg: :cyan, attrs: [:bold]),
29
- }.freeze
30
-
31
20
  HELP = [
32
21
  ["j / k ↑ / ↓", "move"],
33
22
  ["Space", "toggle done"],
@@ -50,10 +39,10 @@ module TodoSample
50
39
  class App
51
40
  def initialize(todos = SEED)
52
41
  @todos = todos.dup
53
- @list = TuiTui::ScrollList.new
42
+ @theme = TuiTui::Theme.auto
43
+ @list = TuiTui::Widget::ScrollList.new
54
44
  @filter = ""
55
- @modal = nil
56
- @on_result = nil
45
+ @host = TuiTui::ModalHost.new
57
46
  @toast = nil
58
47
  sync_list
59
48
  end
@@ -64,32 +53,39 @@ module TodoSample
64
53
  def update(event)
65
54
  @toast = nil if @toast&.expired?
66
55
  return self unless event.is_a?(TuiTui::KeyEvent)
67
- return route_modal(event.key) if @modal
56
+
57
+ if @host.open?
58
+ @host.handle(event)
59
+ sync_list
60
+ return self
61
+ end
68
62
 
69
63
  handle_key(event.key)
70
64
  end
71
65
 
72
66
  def view(size)
73
67
  canvas = TuiTui::Canvas.blank(size)
74
- body_rows = [size.rows - 4, 1].max
75
- body = TuiTui::Rect.new(row: 3, col: 2, rows: body_rows, cols: [size.cols - 2, 1].max)
68
+ header, body, status = TuiTui::Rect.of(size).split_rows(2, :rest, 2)
69
+ # Indent the list one column and keep at least one line even when the
70
+ # terminal is too short for the chrome.
71
+ body = body.with(col: 2, cols: [size.cols - 2, 1].max, rows: [body.rows, 1].max)
76
72
 
77
- draw_header(canvas, size)
73
+ draw_header(canvas, header)
78
74
  draw_list(canvas, body)
79
- draw_status(canvas, size)
80
- @toast&.draw(canvas, size, style: STYLE[:select])
81
- @modal&.draw(canvas, size)
75
+ draw_status(canvas, status)
76
+ @toast&.draw(canvas, size, style: @theme.selection)
77
+ @host.draw(canvas, size)
82
78
  canvas
83
79
  end
84
80
 
85
81
  private
86
82
 
87
- def toast(message) = @toast = TuiTui::Toast.new(message)
83
+ def toast(message) = @toast = TuiTui::Widget::Toast.new(message)
88
84
 
89
85
  def handle_key(key)
90
86
  case key
91
87
  when "q", TuiTui::KeyCode::CTRL_C then return :quit
92
- when "?" then open_modal(TuiTui::Help.new("Todo keys", HELP)) { nil }
88
+ when "?" then @host.open(TuiTui::Modal::Help.new("Todo keys", HELP, theme: @theme))
93
89
  when "a" then prompt_add
94
90
  when "e" then prompt_edit
95
91
  when "d" then confirm_delete
@@ -104,23 +100,8 @@ module TodoSample
104
100
  self
105
101
  end
106
102
 
107
- def open_modal(widget, &on_result)
108
- @modal = widget
109
- @on_result = on_result
110
- end
111
-
112
- def route_modal(key)
113
- result = @modal.handle(key)
114
- return self if result.nil?
115
-
116
- @modal = nil
117
- @on_result.call(result)
118
- sync_list
119
- self
120
- end
121
-
122
103
  def prompt_add
123
- open_modal(TuiTui::Prompt.new("New todo:")) do |result|
104
+ @host.open(TuiTui::Modal::Prompt.new("New todo:", theme: @theme)) do |result|
124
105
  next unless result.is_a?(Array) && result.first == :ok
125
106
 
126
107
  title = result.last.strip
@@ -135,7 +116,7 @@ module TodoSample
135
116
  index = selected_index
136
117
  return unless index
137
118
 
138
- open_modal(TuiTui::Prompt.new("Edit todo:", value: @todos[index].title)) do |result|
119
+ @host.open(TuiTui::Modal::Prompt.new("Edit todo:", value: @todos[index].title, theme: @theme)) do |result|
139
120
  next unless result.is_a?(Array) && result.first == :ok
140
121
 
141
122
  title = result.last.strip
@@ -147,7 +128,7 @@ module TodoSample
147
128
  end
148
129
 
149
130
  def prompt_filter
150
- open_modal(TuiTui::Prompt.new("Filter:", value: @filter)) do |result|
131
+ @host.open(TuiTui::Modal::Prompt.new("Filter:", value: @filter, theme: @theme)) do |result|
151
132
  next unless result.is_a?(Array) && result.first == :ok
152
133
 
153
134
  @filter = result.last.strip
@@ -159,7 +140,7 @@ module TodoSample
159
140
  return unless index
160
141
 
161
142
  title = TuiTui::DisplayText.new(@todos[index].title).truncate(30)
162
- open_modal(TuiTui::Confirm.new("Delete #{title}?", ok: "Delete")) do |result|
143
+ @host.open(TuiTui::Modal::Confirm.new("Delete #{title}?", ok: "Delete", theme: @theme)) do |result|
163
144
  next unless result == :ok
164
145
 
165
146
  @todos.delete_at(index)
@@ -181,51 +162,49 @@ module TodoSample
181
162
  toast(todo.done ? "reopened" : "done")
182
163
  end
183
164
 
184
- def draw_header(canvas, size)
185
- canvas.text(1, 2, "Todo list", STYLE[:title])
165
+ def draw_header(canvas, rect)
166
+ canvas.text(rect.row, 2, "Todo list", @theme.title)
186
167
  if @filter.empty?
187
- canvas.text(1, 14, "#{open_count} open / #{@todos.size} total", STYLE[:dim])
168
+ canvas.text(rect.row, 14, "#{open_count} open / #{@todos.size} total", @theme.muted)
188
169
  else
189
- canvas.text(1, 14, "filter: #{@filter}", STYLE[:filter])
170
+ canvas.text(rect.row, 14, "filter: #{@filter}", @theme.accent)
190
171
  end
191
- canvas.hline(2, 1, size.cols, "-", STYLE[:dim])
172
+ canvas.hline(rect.row + 1, 1, rect.cols, "-", @theme.frame) if rect.rows > 1
192
173
  end
193
174
 
194
175
  def draw_list(canvas, rect)
195
176
  sync_list
196
177
  if visible.empty?
197
178
  message = @filter.empty? ? "No todos. Press a to add one." : "No matches. Press c to clear the filter."
198
- canvas.text(rect.row, rect.col, message, STYLE[:empty])
179
+ canvas.text(rect.row, rect.col, message, @theme.muted)
199
180
  return
200
181
  end
201
182
 
202
- TuiTui::List.new(@list).draw(canvas, rect, highlight: STYLE[:select]) do |visible_index, selected|
183
+ TuiTui::Widget::List.new(@list).draw(canvas, rect, highlight: @theme.selection) do |visible_index, selected|
203
184
  todo = @todos[visible[visible_index]]
204
185
  row_line(todo, selected)
205
186
  end
206
187
  end
207
188
 
208
189
  def row_line(todo, selected)
209
- marker = todo.done ? "[x] " : "[ ] "
210
- state = row_state(todo, selected)
211
- text = todo.done ? STYLE[:dim] : nil
190
+ # The selected row is painted whole with theme.selection (List's
191
+ # highlight); unselected rows color the marker by todo state.
192
+ return TuiTui::Line[TuiTui::Span["#{marker(todo)}#{todo.title}", @theme.selection]] if selected
212
193
 
213
194
  TuiTui::Line[
214
- TuiTui::Span[marker, state],
215
- TuiTui::Span[todo.title, selected ? STYLE[:select] : text],
195
+ TuiTui::Span[marker(todo), todo.done ? @theme.success : @theme.warning],
196
+ TuiTui::Span[todo.title, todo.done ? @theme.muted : @theme.text],
216
197
  ]
217
198
  end
218
199
 
219
- def row_state(todo, selected)
220
- return todo.done ? STYLE[:select_done] : STYLE[:select_pending] if selected
200
+ def marker(todo) = todo.done ? "[x] " : "[ ] "
221
201
 
222
- todo.done ? STYLE[:done] : STYLE[:pending]
223
- end
202
+ def draw_status(canvas, rect)
203
+ return if rect.rows.zero?
224
204
 
225
- def draw_status(canvas, size)
226
205
  status = " a add e edit d delete f filter Space toggle ? help q quit"
227
- canvas.hline(size.rows - 1, 1, size.cols, "-", STYLE[:dim]) if size.rows > 1
228
- canvas.text(size.rows, 1, status, STYLE[:dim])
206
+ canvas.hline(rect.row, 1, rect.cols, "-", @theme.frame) if rect.rows > 1
207
+ canvas.text(rect.row + rect.rows - 1, 1, status, @theme.muted)
229
208
  end
230
209
 
231
210
  def selected_index = visible[@list.cursor]
data/examples/widgets.rb CHANGED
@@ -62,10 +62,10 @@ module WidgetsSample
62
62
  def handle_key(key)
63
63
  case key
64
64
  when "q", TuiTui::KeyCode::CTRL_C then return :quit
65
- when "c" then open(TuiTui::Confirm.new("Proceed?", theme: @theme)) { |r| @last = "confirm -> #{r}" }
66
- when "s" then open(TuiTui::Select.new("Pick a color", ITEMS, theme: @theme)) { |r| @last = "select -> #{label(r)}" }
67
- when "p" then open(TuiTui::Prompt.new("Name:", theme: @theme)) { |r| @last = "prompt -> #{prompt_value(r)}" }
68
- when "?" then open(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
65
+ when "c" then open(TuiTui::Modal::Confirm.new("Proceed?", theme: @theme)) { |r| @last = "confirm -> #{r}" }
66
+ when "s" then open(TuiTui::Modal::Select.new("Pick a color", ITEMS, theme: @theme)) { |r| @last = "select -> #{label(r)}" }
67
+ when "p" then open(TuiTui::Modal::Prompt.new("Name:", theme: @theme)) { |r| @last = "prompt -> #{prompt_value(r)}" }
68
+ when "?" then open(TuiTui::Modal::Help.new("Keys", HELP, theme: @theme)) { nil }
69
69
  when "t" then cycle_theme
70
70
  end
71
71
  self