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/life.rb
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Conway's Game of Life: a tick-driven, full-screen cellular automaton with no
|
|
5
|
+
# widgets and no domain. It shows TuiTui's renderer under heavy load — every
|
|
6
|
+
# generation repaints many cells, and the CanvasCompositor only writes the rows
|
|
7
|
+
# that changed — plus `wants_tick?` animation. Cells are a space in a background
|
|
8
|
+
# color (ASCII-only, N7). The grid is toroidal (wraps at the edges).
|
|
9
|
+
#
|
|
10
|
+
# ruby examples/life.rb
|
|
11
|
+
#
|
|
12
|
+
# Mouse: click or drag to add cells (right-drag removes) — pause to draw a pattern.
|
|
13
|
+
# Keys: Space pause/resume, s single-step (while paused), +/- speed (generations
|
|
14
|
+
# per frame), r reseed, q quit. Rendering runs at ~60 fps (tick 1/60); a frame —
|
|
15
|
+
# step + view + diff — measures well under the 16 ms budget even on a large, dense
|
|
16
|
+
# board, so the CPU is not the limit. +/- accelerates the simulation (more
|
|
17
|
+
# generations per frame) without redrawing more often.
|
|
18
|
+
|
|
19
|
+
require "set"
|
|
20
|
+
require_relative "../lib/tui_tui"
|
|
21
|
+
|
|
22
|
+
module LifeSample
|
|
23
|
+
CELL = TuiTui::Style.new(bg: :green)
|
|
24
|
+
BAR = TuiTui::Style.new(attrs: [:reverse])
|
|
25
|
+
GLIDER = [[0, 1], [1, 2], [2, 0], [2, 1], [2, 2]].freeze
|
|
26
|
+
|
|
27
|
+
class Life
|
|
28
|
+
def initialize(rows: 24, cols: 80)
|
|
29
|
+
@rows = rows
|
|
30
|
+
@cols = cols
|
|
31
|
+
@paused = false
|
|
32
|
+
@gen = 0
|
|
33
|
+
@speed = 1 # generations advanced per tick (render stays one frame per tick)
|
|
34
|
+
reseed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Idle while paused; otherwise the Runtime ticks us to advance generations.
|
|
38
|
+
def wants_tick? = !@paused
|
|
39
|
+
|
|
40
|
+
def update(event)
|
|
41
|
+
case event
|
|
42
|
+
when TuiTui::KeyEvent then handle_key(event.key)
|
|
43
|
+
when TuiTui::MouseEvent then handle_mouse(event)
|
|
44
|
+
when TuiTui::ResizeEvent then resize(event.size)
|
|
45
|
+
when TuiTui::TickEvent then advance
|
|
46
|
+
else self
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def view(size)
|
|
51
|
+
@rows = size.rows
|
|
52
|
+
@cols = size.cols
|
|
53
|
+
canvas = TuiTui::Canvas.blank(size)
|
|
54
|
+
@alive.each do |(row, col)|
|
|
55
|
+
canvas.text(row, col, " ", CELL) if row.between?(1, size.rows - 1) && col.between?(1, size.cols)
|
|
56
|
+
end
|
|
57
|
+
draw_status(canvas, size)
|
|
58
|
+
canvas
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def handle_key(key)
|
|
64
|
+
case key
|
|
65
|
+
when "q", TuiTui::KeyCode::CTRL_C then return :quit
|
|
66
|
+
when " " then @paused = !@paused
|
|
67
|
+
when "s" then step if @paused # single-step
|
|
68
|
+
when "r" then reseed
|
|
69
|
+
when "+", "=" then @speed += 1
|
|
70
|
+
when "-", "_" then @speed = [@speed - 1, 1].max
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resize(size)
|
|
76
|
+
@rows = size.rows
|
|
77
|
+
@cols = size.cols
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Click or drag to bring cells to life (right button kills them). Pause first
|
|
82
|
+
# to draw a pattern; while running a lone cell dies next generation, as Life
|
|
83
|
+
# demands. The status row is left alone.
|
|
84
|
+
def handle_mouse(event)
|
|
85
|
+
return self unless %i[press drag].include?(event.action)
|
|
86
|
+
return self unless event.row.between?(1, @rows - 1)
|
|
87
|
+
|
|
88
|
+
cell = [event.row, event.col]
|
|
89
|
+
event.button == :right ? @alive.delete(cell) : @alive.add(cell)
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Render is one frame per tick; the simulation runs `@speed` generations in
|
|
94
|
+
# between, which decouples animation speed from the render frame rate.
|
|
95
|
+
def advance
|
|
96
|
+
@speed.times { step }
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One generation (B3/S23) on a toroidal grid: a cell lives next gen with 3
|
|
101
|
+
# live neighbors, or 2 if already alive.
|
|
102
|
+
def step
|
|
103
|
+
counts = Hash.new(0)
|
|
104
|
+
@alive.each { |(r, c)| neighbors(r, c).each { |cell| counts[cell] += 1 } }
|
|
105
|
+
@alive = counts.filter_map { |cell, n| cell if n == 3 || (n == 2 && @alive.include?(cell)) }.to_set
|
|
106
|
+
@gen += 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def neighbors(row, col)
|
|
110
|
+
result = []
|
|
111
|
+
(-1..1).each do |dr|
|
|
112
|
+
(-1..1).each do |dc|
|
|
113
|
+
next if dr.zero? && dc.zero?
|
|
114
|
+
|
|
115
|
+
result << [(row + dr - 1) % @rows + 1, (col + dc - 1) % @cols + 1]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Scatter a few gliders so there is immediate, visible motion.
|
|
122
|
+
def reseed
|
|
123
|
+
@gen = 0
|
|
124
|
+
@alive = Set.new
|
|
125
|
+
[[3, 3], [3, 30], [12, 50], [16, 10]].each { |r0, c0| place(GLIDER, r0, c0) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def place(pattern, row0, col0)
|
|
129
|
+
pattern.each { |dr, dc| @alive << [((row0 + dr - 1) % @rows) + 1, ((col0 + dc - 1) % @cols) + 1] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def draw_status(canvas, size)
|
|
133
|
+
state = @paused ? "paused" : "running"
|
|
134
|
+
text = " gen #{@gen} (#{@alive.size} alive, #{state}, #{@speed}x) " \
|
|
135
|
+
"click=draw Space=pause s=step +/-=speed r=reseed q=quit"
|
|
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)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if $PROGRAM_NAME == __FILE__
|
|
143
|
+
TuiTui::Runtime.new(LifeSample::Life.new).run(tick: 1.0 / 60) # ~60 fps render
|
|
144
|
+
end
|
data/examples/paint.rb
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A mouse demo + color/style showcase. Drag in the canvas to paint cells;
|
|
5
|
+
# right-drag erases. Press `p` (or click the color swatch) to open a color
|
|
6
|
+
# picker dialog — a grid of all 256 terminal colors, choosable by arrows+Enter
|
|
7
|
+
# or a mouse click, and movable by dragging its title bar. The header row shows
|
|
8
|
+
# the text attributes as live samples.
|
|
9
|
+
# Cells (and swatches) are a space in a background color (ASCII-only, N7).
|
|
10
|
+
#
|
|
11
|
+
# ruby examples/paint.rb
|
|
12
|
+
#
|
|
13
|
+
# Mouse: drag = paint, right-drag = erase, click the swatch = open colors.
|
|
14
|
+
# Keys: p colors, [ / ] brush size, c clear, q (or Ctrl-C) quit.
|
|
15
|
+
|
|
16
|
+
require_relative "../lib/tui_tui"
|
|
17
|
+
|
|
18
|
+
module PaintSample
|
|
19
|
+
DIM = TuiTui::Style.new(attrs: [:dim])
|
|
20
|
+
BAR = TuiTui::Style.new(attrs: [:reverse])
|
|
21
|
+
TITLE = TuiTui::Style.new(attrs: [:bold])
|
|
22
|
+
ATTRS = %i[bold dim italic underline reverse].freeze
|
|
23
|
+
|
|
24
|
+
STYLES_ROW = 1
|
|
25
|
+
STATUS_ROW = 2
|
|
26
|
+
PAINT_TOP = 3
|
|
27
|
+
SWATCH_COL = " color: ".length + 1 # the status-row swatch column (click to open)
|
|
28
|
+
|
|
29
|
+
# A modal color picker: the 256 terminal colors as a 16-wide grid, chosen with
|
|
30
|
+
# the arrow keys + Enter, or a mouse click. `handle` / `handle_mouse` return the
|
|
31
|
+
# chosen color index, :cancel, or nil while still open. The grid geometry is
|
|
32
|
+
# recorded at draw time so a click can be hit-tested against the current frame.
|
|
33
|
+
class ColorPicker
|
|
34
|
+
COLS = 16
|
|
35
|
+
COUNT = 256
|
|
36
|
+
SW = 2 # swatch width
|
|
37
|
+
|
|
38
|
+
attr_reader :pos # [row, col] top-left, or nil if never moved (so the app can reopen it in place)
|
|
39
|
+
|
|
40
|
+
def initialize(current: 0, pos: nil)
|
|
41
|
+
@cursor = current
|
|
42
|
+
@pos = pos # [row, col] top-left once the dialog has been moved; nil = centered
|
|
43
|
+
@rect = nil # the current frame rect, recorded at draw time for hit-testing
|
|
44
|
+
@drag = nil # [dy, dx]: the pointer's offset within the dialog while dragging it
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handle(key)
|
|
48
|
+
case key
|
|
49
|
+
when :left then move(-1)
|
|
50
|
+
when :right then move(1)
|
|
51
|
+
when :up then move(-COLS)
|
|
52
|
+
when :down then move(COLS)
|
|
53
|
+
when "\r", " " then @cursor
|
|
54
|
+
when :escape, TuiTui::KeyCode::CTRL_C then :cancel
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Press on the title bar -> start moving the dialog; press on a swatch -> pick
|
|
59
|
+
# it; drag -> move; release -> stop. Returns the chosen index, or nil (open).
|
|
60
|
+
def handle_mouse(event)
|
|
61
|
+
case event.action
|
|
62
|
+
when :press then press(event)
|
|
63
|
+
when :drag then continue_drag(event)
|
|
64
|
+
when :release then end_drag
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def draw(canvas, size)
|
|
69
|
+
@rect = frame_rect(size)
|
|
70
|
+
canvas.frame(@rect)
|
|
71
|
+
title = TuiTui::DisplayText.new("pick a color: #{@cursor} (drag title to move)")
|
|
72
|
+
canvas.text(@rect.row + 1, @rect.col + 2, title.truncate(@rect.cols - 4), TITLE)
|
|
73
|
+
COUNT.times { |i| draw_swatch(canvas, i) }
|
|
74
|
+
canvas
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def frame_rect(size)
|
|
80
|
+
width = (COLS * SW) + 4
|
|
81
|
+
height = (COUNT / COLS) + 4
|
|
82
|
+
return TuiTui::Rect.centered(size, cols: width, rows: height) unless @pos
|
|
83
|
+
|
|
84
|
+
TuiTui::Rect.new(
|
|
85
|
+
row: @pos[0].clamp(1, [size.rows - height + 1, 1].max),
|
|
86
|
+
col: @pos[1].clamp(1, [size.cols - width + 1, 1].max),
|
|
87
|
+
rows: height, cols: width
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def grid_top = @rect.row + 3
|
|
92
|
+
def grid_left = @rect.col + 2
|
|
93
|
+
|
|
94
|
+
def draw_swatch(canvas, index)
|
|
95
|
+
row = grid_top + (index / COLS)
|
|
96
|
+
col = grid_left + ((index % COLS) * SW)
|
|
97
|
+
if index == @cursor
|
|
98
|
+
canvas.text(row, col, "[]", TuiTui::Style.new(bg: index, fg: :bright_white))
|
|
99
|
+
else
|
|
100
|
+
canvas.text(row, col, " ", TuiTui::Style.new(bg: index))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def press(event)
|
|
105
|
+
if on_title_bar?(event)
|
|
106
|
+
@drag = [event.row - @rect.row, event.col - @rect.col] # pointer offset within the dialog
|
|
107
|
+
nil
|
|
108
|
+
else
|
|
109
|
+
hit(event.row, event.col)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def continue_drag(event)
|
|
114
|
+
return nil unless @drag
|
|
115
|
+
|
|
116
|
+
@pos = [event.row - @drag[0], event.col - @drag[1]] # keep the grab point under the pointer
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def end_drag
|
|
121
|
+
@drag = nil
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The top border + title row: grabbing here moves the dialog.
|
|
126
|
+
def on_title_bar?(event)
|
|
127
|
+
@rect && event.row.between?(@rect.row, @rect.row + 1) &&
|
|
128
|
+
event.col.between?(@rect.col, @rect.col + @rect.cols - 1)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def move(delta)
|
|
132
|
+
@cursor = (@cursor + delta).clamp(0, COUNT - 1)
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def hit(row, col)
|
|
137
|
+
grow = row - grid_top
|
|
138
|
+
gcol = (col - grid_left) / SW
|
|
139
|
+
return nil unless col >= grid_left && grow.between?(0, (COUNT / COLS) - 1) && gcol.between?(0, COLS - 1)
|
|
140
|
+
|
|
141
|
+
(grow * COLS) + gcol
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class Paint
|
|
146
|
+
def initialize
|
|
147
|
+
@cells = {} # [row, col] => color index
|
|
148
|
+
@color = 9 # bright red
|
|
149
|
+
@brush = 1
|
|
150
|
+
@rows = 24
|
|
151
|
+
@modal = nil
|
|
152
|
+
@picker_pos = nil # where the picker was last left, so it reopens in place
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def update(event)
|
|
156
|
+
case event
|
|
157
|
+
when TuiTui::KeyEvent then @modal ? route(@modal.handle(event.key)) : handle_key(event.key)
|
|
158
|
+
when TuiTui::MouseEvent then @modal ? route(@modal.handle_mouse(event)) : handle_mouse(event)
|
|
159
|
+
else self
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def view(size)
|
|
164
|
+
@rows = size.rows
|
|
165
|
+
canvas = TuiTui::Canvas.blank(size)
|
|
166
|
+
@cells.each do |(row, col), color|
|
|
167
|
+
next unless row.between?(PAINT_TOP, size.rows) && col.between?(1, size.cols)
|
|
168
|
+
|
|
169
|
+
canvas.text(row, col, " ", TuiTui::Style.new(bg: color))
|
|
170
|
+
end
|
|
171
|
+
draw_styles(canvas)
|
|
172
|
+
draw_status(canvas, size)
|
|
173
|
+
@modal&.draw(canvas, size)
|
|
174
|
+
canvas
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Resolve a modal result: an Integer is the picked color; :cancel keeps the
|
|
180
|
+
# current one; nil means the dialog is still open.
|
|
181
|
+
def route(result)
|
|
182
|
+
return self if result.nil?
|
|
183
|
+
|
|
184
|
+
@color = result if result.is_a?(Integer)
|
|
185
|
+
@picker_pos = @modal.pos # remember where it was so the next open lands there
|
|
186
|
+
@modal = nil
|
|
187
|
+
self
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def handle_key(key)
|
|
191
|
+
case key
|
|
192
|
+
when "q", TuiTui::KeyCode::CTRL_C then return :quit
|
|
193
|
+
when "p" then @modal = ColorPicker.new(current: @color, pos: @picker_pos)
|
|
194
|
+
when "c" then @cells = {}
|
|
195
|
+
when "]" then @brush += 1
|
|
196
|
+
when "[" then @brush = [@brush - 1, 1].max
|
|
197
|
+
end
|
|
198
|
+
self
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def handle_mouse(event)
|
|
202
|
+
return self unless %i[press drag].include?(event.action)
|
|
203
|
+
|
|
204
|
+
if event.row == STATUS_ROW && (SWATCH_COL..SWATCH_COL + 1).cover?(event.col)
|
|
205
|
+
@modal = ColorPicker.new(current: @color, pos: @picker_pos)
|
|
206
|
+
elsif event.row >= PAINT_TOP
|
|
207
|
+
stroke(event)
|
|
208
|
+
end
|
|
209
|
+
self
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Paint (or erase) a brush-sized square centered on the pointer.
|
|
213
|
+
def stroke(event)
|
|
214
|
+
top = event.row - ((@brush - 1) / 2)
|
|
215
|
+
left = event.col - ((@brush - 1) / 2)
|
|
216
|
+
@brush.times do |dr|
|
|
217
|
+
@brush.times do |dc|
|
|
218
|
+
row = top + dr
|
|
219
|
+
col = left + dc
|
|
220
|
+
next unless row.between?(PAINT_TOP, @rows) && col >= 1
|
|
221
|
+
|
|
222
|
+
cell = [row, col]
|
|
223
|
+
event.button == :right ? @cells.delete(cell) : @cells[cell] = @color
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def draw_styles(canvas)
|
|
229
|
+
spans = [TuiTui::Span["styles: ", DIM]]
|
|
230
|
+
ATTRS.each { |attr| spans << TuiTui::Span[attr.to_s, TuiTui::Style.new(attrs: [attr])] << TuiTui::Span[" "] }
|
|
231
|
+
canvas.line(STYLES_ROW, 1, spans) # Canvas advances the column per span
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def draw_status(canvas, size)
|
|
235
|
+
canvas.fill(TuiTui::Rect.new(row: STATUS_ROW, col: 1, rows: 1, cols: size.cols), BAR)
|
|
236
|
+
canvas.text(STATUS_ROW, 1, " color: ", BAR)
|
|
237
|
+
canvas.text(STATUS_ROW, SWATCH_COL, " ", TuiTui::Style.new(bg: @color))
|
|
238
|
+
hints = " ##{@color} brush #{@brush} p=colors [ ]=size right-drag=erase c=clear q=quit"
|
|
239
|
+
canvas.text(STATUS_ROW, SWATCH_COL + 2, TuiTui::DisplayText.new(hints).truncate(size.cols - SWATCH_COL - 1), BAR)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if $PROGRAM_NAME == __FILE__
|
|
245
|
+
TuiTui::Runtime.new(PaintSample::Paint.new).run
|
|
246
|
+
end
|
data/examples/todo.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A small but usable todo list. It demonstrates a typical TuiTui app shape:
|
|
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.
|
|
7
|
+
#
|
|
8
|
+
# ruby examples/todo.rb
|
|
9
|
+
#
|
|
10
|
+
# Keys: j/k (or ↑/↓) move, Space toggle, a add, e edit, d delete, f filter,
|
|
11
|
+
# c clear filter, ? help, q (or Ctrl-C) quit.
|
|
12
|
+
|
|
13
|
+
require_relative "../lib/tui_tui"
|
|
14
|
+
|
|
15
|
+
module TodoSample
|
|
16
|
+
Todo = Data.define(:title, :done)
|
|
17
|
+
|
|
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
|
+
HELP = [
|
|
32
|
+
["j / k ↑ / ↓", "move"],
|
|
33
|
+
["Space", "toggle done"],
|
|
34
|
+
["a", "add a todo"],
|
|
35
|
+
["e", "edit selected todo"],
|
|
36
|
+
["d", "delete selected todo"],
|
|
37
|
+
["f", "filter by text"],
|
|
38
|
+
["c", "clear filter"],
|
|
39
|
+
["g / G", "top / bottom"],
|
|
40
|
+
["?", "this help"],
|
|
41
|
+
["q", "quit"],
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
SEED = [
|
|
45
|
+
Todo.new("Add a useful example", false),
|
|
46
|
+
Todo.new("Keep rendering width-safe", true),
|
|
47
|
+
Todo.new("日本語のタスクも崩れない", false),
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
class App
|
|
51
|
+
def initialize(todos = SEED)
|
|
52
|
+
@todos = todos.dup
|
|
53
|
+
@list = TuiTui::ScrollList.new
|
|
54
|
+
@filter = ""
|
|
55
|
+
@modal = nil
|
|
56
|
+
@on_result = nil
|
|
57
|
+
@toast = nil
|
|
58
|
+
sync_list
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Keep ticking only while a toast is showing, so it auto-dismisses.
|
|
62
|
+
def wants_tick? = !@toast.nil?
|
|
63
|
+
|
|
64
|
+
def update(event)
|
|
65
|
+
@toast = nil if @toast&.expired?
|
|
66
|
+
return self unless event.is_a?(TuiTui::KeyEvent)
|
|
67
|
+
return route_modal(event.key) if @modal
|
|
68
|
+
|
|
69
|
+
handle_key(event.key)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def view(size)
|
|
73
|
+
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)
|
|
76
|
+
|
|
77
|
+
draw_header(canvas, size)
|
|
78
|
+
draw_list(canvas, body)
|
|
79
|
+
draw_status(canvas, size)
|
|
80
|
+
@toast&.draw(canvas, size, style: STYLE[:select])
|
|
81
|
+
@modal&.draw(canvas, size)
|
|
82
|
+
canvas
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def toast(message) = @toast = TuiTui::Toast.new(message)
|
|
88
|
+
|
|
89
|
+
def handle_key(key)
|
|
90
|
+
case key
|
|
91
|
+
when "q", TuiTui::KeyCode::CTRL_C then return :quit
|
|
92
|
+
when "?" then open_modal(TuiTui::Help.new("Todo keys", HELP)) { nil }
|
|
93
|
+
when "a" then prompt_add
|
|
94
|
+
when "e" then prompt_edit
|
|
95
|
+
when "d" then confirm_delete
|
|
96
|
+
when "f" then prompt_filter
|
|
97
|
+
when "c" then clear_filter
|
|
98
|
+
when " ", "\r" then toggle
|
|
99
|
+
when "j", :down then @list.move(1)
|
|
100
|
+
when "k", :up then @list.move(-1)
|
|
101
|
+
when "g", :home then @list.to_top
|
|
102
|
+
when "G", :end then @list.to_end
|
|
103
|
+
end
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
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
|
+
def prompt_add
|
|
123
|
+
open_modal(TuiTui::Prompt.new("New todo:")) do |result|
|
|
124
|
+
next unless result.is_a?(Array) && result.first == :ok
|
|
125
|
+
|
|
126
|
+
title = result.last.strip
|
|
127
|
+
next if title.empty?
|
|
128
|
+
|
|
129
|
+
@todos << Todo.new(title, false)
|
|
130
|
+
toast("added: #{title}")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def prompt_edit
|
|
135
|
+
index = selected_index
|
|
136
|
+
return unless index
|
|
137
|
+
|
|
138
|
+
open_modal(TuiTui::Prompt.new("Edit todo:", value: @todos[index].title)) do |result|
|
|
139
|
+
next unless result.is_a?(Array) && result.first == :ok
|
|
140
|
+
|
|
141
|
+
title = result.last.strip
|
|
142
|
+
next if title.empty?
|
|
143
|
+
|
|
144
|
+
@todos[index] = @todos[index].with(title: title)
|
|
145
|
+
toast("updated")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def prompt_filter
|
|
150
|
+
open_modal(TuiTui::Prompt.new("Filter:", value: @filter)) do |result|
|
|
151
|
+
next unless result.is_a?(Array) && result.first == :ok
|
|
152
|
+
|
|
153
|
+
@filter = result.last.strip
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def confirm_delete
|
|
158
|
+
index = selected_index
|
|
159
|
+
return unless index
|
|
160
|
+
|
|
161
|
+
title = TuiTui::DisplayText.new(@todos[index].title).truncate(30)
|
|
162
|
+
open_modal(TuiTui::Confirm.new("Delete #{title}?", ok: "Delete")) do |result|
|
|
163
|
+
next unless result == :ok
|
|
164
|
+
|
|
165
|
+
@todos.delete_at(index)
|
|
166
|
+
toast("deleted")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clear_filter
|
|
171
|
+
@filter = ""
|
|
172
|
+
sync_list
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def toggle
|
|
176
|
+
index = selected_index
|
|
177
|
+
return unless index
|
|
178
|
+
|
|
179
|
+
todo = @todos[index]
|
|
180
|
+
@todos[index] = todo.with(done: !todo.done)
|
|
181
|
+
toast(todo.done ? "reopened" : "done")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def draw_header(canvas, size)
|
|
185
|
+
canvas.text(1, 2, "Todo list", STYLE[:title])
|
|
186
|
+
if @filter.empty?
|
|
187
|
+
canvas.text(1, 14, "#{open_count} open / #{@todos.size} total", STYLE[:dim])
|
|
188
|
+
else
|
|
189
|
+
canvas.text(1, 14, "filter: #{@filter}", STYLE[:filter])
|
|
190
|
+
end
|
|
191
|
+
canvas.hline(2, 1, size.cols, "-", STYLE[:dim])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def draw_list(canvas, rect)
|
|
195
|
+
sync_list
|
|
196
|
+
if visible.empty?
|
|
197
|
+
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])
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
TuiTui::List.new(@list).draw(canvas, rect, highlight: STYLE[:select]) do |visible_index, selected|
|
|
203
|
+
todo = @todos[visible[visible_index]]
|
|
204
|
+
row_line(todo, selected)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def row_line(todo, selected)
|
|
209
|
+
marker = todo.done ? "[x] " : "[ ] "
|
|
210
|
+
state = row_state(todo, selected)
|
|
211
|
+
text = todo.done ? STYLE[:dim] : nil
|
|
212
|
+
|
|
213
|
+
TuiTui::Line[
|
|
214
|
+
TuiTui::Span[marker, state],
|
|
215
|
+
TuiTui::Span[todo.title, selected ? STYLE[:select] : text],
|
|
216
|
+
]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def row_state(todo, selected)
|
|
220
|
+
return todo.done ? STYLE[:select_done] : STYLE[:select_pending] if selected
|
|
221
|
+
|
|
222
|
+
todo.done ? STYLE[:done] : STYLE[:pending]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def draw_status(canvas, size)
|
|
226
|
+
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])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def selected_index = visible[@list.cursor]
|
|
232
|
+
|
|
233
|
+
def visible
|
|
234
|
+
return (0...@todos.size).to_a if @filter.empty?
|
|
235
|
+
|
|
236
|
+
needle = @filter.downcase
|
|
237
|
+
@todos.each_index.select { |index| @todos[index].title.downcase.include?(needle) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def sync_list
|
|
241
|
+
@list.count = visible.size
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def open_count = @todos.count { |todo| !todo.done }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if $PROGRAM_NAME == __FILE__
|
|
249
|
+
TuiTui::Runtime.new(TodoSample::App.new).run
|
|
250
|
+
end
|