tui_tui 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +51 -14
- data/examples/README.md +28 -0
- data/examples/breakout.rb +225 -0
- data/examples/counter.rb +11 -12
- data/examples/csv_viewer.rb +28 -50
- data/examples/downloads.rb +128 -0
- data/examples/file_browser.rb +64 -55
- data/examples/form.rb +80 -102
- data/examples/life.rb +1 -1
- data/examples/log_viewer.rb +118 -0
- data/examples/todo.rb +43 -64
- data/examples/widgets.rb +4 -4
- data/lib/tui_tui/box_chrome.rb +0 -9
- data/lib/tui_tui/box_chrome_resolver.rb +21 -0
- data/lib/tui_tui/canvas.rb +6 -55
- data/lib/tui_tui/canvas_compositor.rb +12 -10
- data/lib/tui_tui/canvas_renderer.rb +59 -0
- data/lib/tui_tui/cell.rb +4 -3
- data/lib/tui_tui/command.rb +21 -0
- data/lib/tui_tui/event_stream.rb +42 -7
- data/lib/tui_tui/key_reader.rb +1 -1
- data/lib/tui_tui/modal/command_palette.rb +193 -0
- data/lib/tui_tui/modal/confirm.rb +74 -0
- data/lib/tui_tui/modal/help.rb +44 -0
- data/lib/tui_tui/modal/pager.rb +106 -0
- data/lib/tui_tui/modal/prompt.rb +66 -0
- data/lib/tui_tui/modal/select.rb +103 -0
- data/lib/tui_tui/modal.rb +4 -0
- data/lib/tui_tui/modal_host.rb +2 -2
- data/lib/tui_tui/rect.rb +69 -0
- data/lib/tui_tui/runtime.rb +46 -23
- data/lib/tui_tui/screen.rb +7 -3
- data/lib/tui_tui/test_runtime.rb +71 -0
- data/lib/tui_tui/text_input.rb +168 -0
- data/lib/tui_tui/text_sanitizer.rb +3 -10
- data/lib/tui_tui/theme.rb +25 -27
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui/widget/list.rb +59 -0
- data/lib/tui_tui/widget/progress.rb +33 -0
- data/lib/tui_tui/widget/scroll_list.rb +59 -0
- data/lib/tui_tui/widget/scrollbar.rb +43 -0
- data/lib/tui_tui/widget/spinner.rb +37 -0
- data/lib/tui_tui/widget/status_bar.rb +25 -0
- data/lib/tui_tui/widget/table.rb +81 -0
- data/lib/tui_tui/widget/tabs.rb +67 -0
- data/lib/tui_tui/widget/text_view.rb +47 -0
- data/lib/tui_tui/widget/toast.rb +83 -0
- data/lib/tui_tui.rb +23 -15
- metadata +28 -15
- data/lib/tui_tui/command_palette.rb +0 -190
- data/lib/tui_tui/confirm.rb +0 -74
- data/lib/tui_tui/help.rb +0 -44
- data/lib/tui_tui/list.rb +0 -57
- data/lib/tui_tui/pager.rb +0 -106
- data/lib/tui_tui/prompt.rb +0 -108
- data/lib/tui_tui/scroll_list.rb +0 -57
- data/lib/tui_tui/scrollbar.rb +0 -41
- data/lib/tui_tui/select.rb +0 -103
- data/lib/tui_tui/status_bar.rb +0 -23
- data/lib/tui_tui/text_view.rb +0 -45
- data/lib/tui_tui/toast.rb +0 -83
- /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
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
@
|
|
87
|
-
@
|
|
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 = @
|
|
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
|
|
102
|
-
when
|
|
103
|
-
when
|
|
104
|
-
when
|
|
105
|
-
when
|
|
106
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
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? ? [
|
|
127
|
+
@lines = value.empty? ? [""] : value.split("\n", -1)
|
|
159
128
|
@row = @lines.size - 1
|
|
160
|
-
@
|
|
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.
|
|
167
|
-
def summary = value.empty? ? "(empty)" : "#{@lines.size} line(s), #{
|
|
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 { @
|
|
177
|
-
when :end, CTRL_E then edit { @
|
|
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 {
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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 @
|
|
227
|
-
elsif @row.positive? then @row
|
|
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 @
|
|
233
|
-
elsif @row < @lines.size - 1 then @row
|
|
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
|
-
|
|
239
|
-
@lines
|
|
240
|
-
@row
|
|
241
|
-
@
|
|
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 @
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 @
|
|
259
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
@
|
|
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
|
|
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
|
-
@
|
|
42
|
+
@theme = TuiTui::Theme.auto
|
|
43
|
+
@list = TuiTui::Widget::ScrollList.new
|
|
54
44
|
@filter = ""
|
|
55
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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,
|
|
73
|
+
draw_header(canvas, header)
|
|
78
74
|
draw_list(canvas, body)
|
|
79
|
-
draw_status(canvas,
|
|
80
|
-
@toast&.draw(canvas, size, style:
|
|
81
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
185
|
-
canvas.text(
|
|
165
|
+
def draw_header(canvas, rect)
|
|
166
|
+
canvas.text(rect.row, 2, "Todo list", @theme.title)
|
|
186
167
|
if @filter.empty?
|
|
187
|
-
canvas.text(
|
|
168
|
+
canvas.text(rect.row, 14, "#{open_count} open / #{@todos.size} total", @theme.muted)
|
|
188
169
|
else
|
|
189
|
-
canvas.text(
|
|
170
|
+
canvas.text(rect.row, 14, "filter: #{@filter}", @theme.accent)
|
|
190
171
|
end
|
|
191
|
-
canvas.hline(
|
|
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,
|
|
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:
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
|
215
|
-
TuiTui::Span[todo.title,
|
|
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
|
|
220
|
-
return todo.done ? STYLE[:select_done] : STYLE[:select_pending] if selected
|
|
200
|
+
def marker(todo) = todo.done ? "[x] " : "[ ] "
|
|
221
201
|
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
228
|
-
canvas.text(
|
|
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
|