tui_tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. metadata +98 -0
data/examples/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