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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -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 +64 -55
  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/canvas.rb +6 -55
  18. data/lib/tui_tui/canvas_compositor.rb +12 -10
  19. data/lib/tui_tui/canvas_renderer.rb +59 -0
  20. data/lib/tui_tui/cell.rb +4 -3
  21. data/lib/tui_tui/command.rb +21 -0
  22. data/lib/tui_tui/event_stream.rb +42 -7
  23. data/lib/tui_tui/key_reader.rb +1 -1
  24. data/lib/tui_tui/modal/command_palette.rb +193 -0
  25. data/lib/tui_tui/modal/confirm.rb +74 -0
  26. data/lib/tui_tui/modal/help.rb +44 -0
  27. data/lib/tui_tui/modal/pager.rb +106 -0
  28. data/lib/tui_tui/modal/prompt.rb +66 -0
  29. data/lib/tui_tui/modal/select.rb +103 -0
  30. data/lib/tui_tui/modal.rb +4 -0
  31. data/lib/tui_tui/modal_host.rb +2 -2
  32. data/lib/tui_tui/rect.rb +69 -0
  33. data/lib/tui_tui/runtime.rb +46 -23
  34. data/lib/tui_tui/screen.rb +7 -3
  35. data/lib/tui_tui/test_runtime.rb +71 -0
  36. data/lib/tui_tui/text_input.rb +168 -0
  37. data/lib/tui_tui/text_sanitizer.rb +3 -10
  38. data/lib/tui_tui/theme.rb +25 -27
  39. data/lib/tui_tui/version.rb +1 -1
  40. data/lib/tui_tui/widget/list.rb +59 -0
  41. data/lib/tui_tui/widget/progress.rb +33 -0
  42. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  43. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  44. data/lib/tui_tui/widget/spinner.rb +37 -0
  45. data/lib/tui_tui/widget/status_bar.rb +25 -0
  46. data/lib/tui_tui/widget/table.rb +81 -0
  47. data/lib/tui_tui/widget/tabs.rb +67 -0
  48. data/lib/tui_tui/widget/text_view.rb +47 -0
  49. data/lib/tui_tui/widget/toast.rb +83 -0
  50. data/lib/tui_tui.rb +23 -15
  51. metadata +28 -15
  52. data/lib/tui_tui/command_palette.rb +0 -190
  53. data/lib/tui_tui/confirm.rb +0 -74
  54. data/lib/tui_tui/help.rb +0 -44
  55. data/lib/tui_tui/list.rb +0 -57
  56. data/lib/tui_tui/pager.rb +0 -106
  57. data/lib/tui_tui/prompt.rb +0 -108
  58. data/lib/tui_tui/scroll_list.rb +0 -57
  59. data/lib/tui_tui/scrollbar.rb +0 -41
  60. data/lib/tui_tui/select.rb +0 -103
  61. data/lib/tui_tui/status_bar.rb +0 -23
  62. data/lib/tui_tui/text_view.rb +0 -45
  63. data/lib/tui_tui/toast.rb +0 -83
  64. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 509e482a4d22483da9840ee1ea39021f27f747f2c6919d7782e46f41dbfdfea0
4
- data.tar.gz: 731b150add99bd21eafef87e2a33c36679eb5998f01a78bf317217a1c7ee428a
3
+ metadata.gz: 1b33867fe04eb8d9683aad497f5695c4acd69d73f21c499f0b56f6bb9894ac59
4
+ data.tar.gz: 63803c2a7b40bcd5ea08a87646ad676566bbfdd25d5dc60003260d9ea65073ee
5
5
  SHA512:
6
- metadata.gz: '068472d5d8dd0c5ae371ffe2516bbe1f1a548d3d55c97e85b898e821640ed6a64b0e8005fa3e8c219c20cfae79e1e86efafbb2f851f7dbbc6830e5ec2e59c455'
7
- data.tar.gz: 37e91b9643cc30df3a887e90004859cc37035c52882f353a66148fce585d47c1ba1075af142650fad597e3d34652b9a1983a11f2c19019163740920cff4afadf
6
+ metadata.gz: 421ac35ee26b15078161127094d180174bc4268b06e6268ea0c7970ec2fb15bdddda8a1fde6f11b5075f6ba91fcfef75d4e0b60e918c3daf5a7649b86d4203a0
7
+ data.tar.gz: 1b9049e7b536309c85eff02c8864c6b08d7567b62f359d440ba2aaa07aa5715c2cd1c26fe29fff66b3a8ff7f5097232ab649201e8dcd5a2b309c11616166d037
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-07-03
4
+
5
+ ### Added
6
+ - `Widget::Table`: a fixed-header table for `ScrollList` — `[label, width]`
7
+ columns, cell content from the caller's block, width-aware (CJK safe).
8
+ See `examples/csv_viewer.rb`.
9
+ - `Widget::Tabs`: a one-row tab bar with click hit-testing (`Tabs.draw` /
10
+ `Tabs.index_at`). See `examples/log_viewer.rb`.
11
+ - `Widget::Spinner` and `Widget::Progress`: a tick-driven frame cycler and a
12
+ progress bar. See `examples/downloads.rb`.
13
+ - New examples — `downloads.rb`, `log_viewer.rb`, `breakout.rb` — and an
14
+ `examples/README.md` guide.
15
+ - `Rect#split_rows` / `Rect#split_cols`: split a Rect from a constraint list
16
+ (Integer = fixed size, Float = fraction, `:rest` = the leftover); an
17
+ undersized rect clamps trailing segments to 0 instead of raising.
18
+ `Rect.of(size)` builds the full-screen root Rect.
19
+ - `TuiTui::TestRuntime`: drive an app without a terminal (opt-in via
20
+ `require "tui_tui/test_runtime"`) — inject `key` / `type` / `tick` /
21
+ `resize` / `mouse`, then assert on `app`, `commands`, `quit?`, and the
22
+ rendered `screen` / `canvas`.
23
+ - `TuiTui::Command`: `update` may return `[app, *commands]` to request
24
+ one-shot effects — `Command::Copy` (clipboard, OSC 52) and
25
+ `Command::Invalidate` (full repaint). `[:quit, *commands]` performs the
26
+ commands and then quits.
27
+ - `TextInput`: a single-line, grapheme-aware text-editing model; it now backs
28
+ `Modal::Prompt` and `Modal::CommandPalette`.
29
+ - `Canvas#to_text`: a style-free string projection of the grid, for snapshot
30
+ tests.
31
+
32
+ ### Fixed
33
+ - Continuous input (e.g. terminal key repeat) no longer starves `TickEvent`,
34
+ so holding a key down cannot freeze animations or keep a `Widget::Toast`
35
+ from expiring — and ticks cannot starve input either.
36
+ - `Modal::Prompt` no longer overflows its field: a long value scrolls
37
+ horizontally to keep the cursor visible.
38
+ - `Modal::CommandPalette` backspace deletes a whole grapheme (base plus
39
+ combining marks), not half of one.
40
+
41
+ ### Changed
42
+ - `tick:` is now a period, not an idle timeout: `TickEvent` arrives on a
43
+ fixed cadence, between input events too.
44
+ - Injectable clocks take a Clock-compatible object (responding to
45
+ `monotonic`) instead of a callable: `Widget::Toast.new(clock:)` no longer
46
+ accepts a lambda.
47
+ - Widgets are namespaced under `TuiTui::Widget` (`List`, `ScrollList`,
48
+ `TextView`, `Scrollbar`, `StatusBar`, `Toast`) and modals under
49
+ `TuiTui::Modal` (`Confirm`, `Select`, `Prompt`, `Pager`, `Help`,
50
+ `CommandPalette`).
51
+ - `BoxChrome.resolve` is now `BoxChromeResolver#resolve` (`BoxChrome` is a
52
+ pure glyph set), and `Canvas`'s row rendering moved to `CanvasRenderer`
53
+ (`Canvas` is a pure drawing surface).
54
+
55
+ ### Removed
56
+ - `TextSanitizer.printable?` (public since 0.3.0): use `TextInput#type`,
57
+ which inserts only printable input.
58
+ - The `take_clipboard` hook: return `Command::Copy.new(text)` from `update`
59
+ instead.
60
+ - The `redraw?(event)` hook: return `Command::Invalidate.new` from `update`
61
+ instead.
62
+
3
63
  ## [0.3.0] - 2026-06-22
4
64
 
5
65
  ### Added
data/README.md CHANGED
@@ -8,7 +8,9 @@ It uses a lightweight, TEA-inspired (MVU) architecture: the app object is the mo
8
8
  ## Usage
9
9
 
10
10
  An app is any object with two methods: `view(size)` returns a `Canvas`, and
11
- `update(event)` returns the next app (or `:quit`). `Runtime#run` drives the loop.
11
+ `update(event)` returns the next app (or `:quit`). It may also return
12
+ `[app, *commands]` to request one-shot effects (see below). `Runtime#run`
13
+ drives the loop.
12
14
 
13
15
  ```ruby
14
16
  require "tui_tui"
@@ -21,12 +23,10 @@ class Counter
21
23
  end
22
24
 
23
25
  def update(event)
24
- return self unless event.is_a?(TuiTui::KeyEvent)
25
-
26
- case event.key
27
- when "+", "=" then Counter.new(@count + 1)
28
- when "-", "_" then Counter.new(@count - 1)
29
- when "q", TuiTui::KeyCode::CTRL_C then :quit
26
+ case event
27
+ in TuiTui::KeyEvent(key: "+" | "=") then Counter.new(@count + 1)
28
+ in TuiTui::KeyEvent(key: "-" | "_") then Counter.new(@count - 1)
29
+ in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then :quit
30
30
  else self
31
31
  end
32
32
  end
@@ -35,14 +35,51 @@ end
35
35
  TuiTui::Runtime.new(Counter.new).run
36
36
  ```
37
37
 
38
- See [`examples/`](examples) for larger apps.
39
- Each is runnable with `ruby examples/<name>.rb`.
38
+ Events are `Data.define` values, so Ruby's pattern matching (`case`/`in`)
39
+ deconstructs them directly in `update`.
40
+
41
+ The runtime never inspects the model, only `update`'s return value, so both
42
+ state styles work: the counter above returns a fresh model from each branch;
43
+ `todo.rb` and `file_browser.rb` mutate their state and return `self`. Prefer
44
+ immutability when you want undo or to compare models in tests; prefer
45
+ mutation when the model holds lists, caches, or widget state.
46
+
47
+ See [`examples/`](./examples/) for larger apps, each runnable with
48
+ `ruby examples/<name>.rb`.
49
+
50
+ ## Commands and subscriptions
51
+
52
+ Ongoing declarations are predicate methods the runtime polls each frame
53
+ (`wants_tick?`, `wants_mouse?`); one-shot instructions are `TuiTui::Command`
54
+ values returned from `update`, which the runtime executes before rendering.
55
+
56
+ ```ruby
57
+ [self, TuiTui::Command::Copy.new(path)] # write to the clipboard (OSC 52)
58
+ ```
59
+
60
+ `Command::Invalidate` requests a full repaint on the next render.
61
+ Returning `[:quit, *commands]` performs the commands and then quits.
62
+
63
+ ## Testing
64
+
65
+ `TuiTui::TestRuntime` is the runtime with the screen removed: it folds
66
+ injected events exactly like `Runtime` and renders `view` headless. It is
67
+ opt-in — `require "tui_tui/test_runtime"`.
68
+
69
+ ```ruby
70
+ require "tui_tui/test_runtime"
71
+
72
+ runtime = TuiTui::TestRuntime.new(Counter.new, rows: 3, cols: 60)
73
+ runtime.key("+").key("+").key("-")
74
+ expect(runtime.screen).to include("count: 1")
75
+ expect(runtime.commands).to eq([])
76
+ expect(runtime.key("q").quit?).to be(true)
77
+ ```
40
78
 
41
- - `examples/counter.rb` the smallest possible app
42
- - `examples/widgets.rb` built-in modal widgets
43
- - `examples/file_browser.rb` two-pane file browser
44
- - `examples/todo.rb` todo list with prompts and filtering
45
- - `examples/csv_viewer.rb` — fixed-header CSV table viewer
79
+ `screen` returns `Canvas#to_text`, a style-free string projection of the
80
+ grid; `canvas` returns the `Canvas` itself for cell-level style assertions.
81
+ `type("…")` injects a string one grapheme at a time, and `resize` / `tick` /
82
+ `mouse` inject the other event kinds.
46
83
 
47
84
  ## Non-functional requirements
48
85
 
@@ -0,0 +1,28 @@
1
+ # Examples
2
+
3
+ Each example is a self-contained app, runnable with `ruby examples/<name>.rb`.
4
+
5
+ ## Learning path
6
+
7
+ Read these in order to learn the app shape:
8
+
9
+ - [`counter.rb`](counter.rb) — the smallest possible app
10
+ - [`clock.rb`](clock.rb) — the smallest ticking app (`wants_tick?`)
11
+ - [`todo.rb`](todo.rb) — the typical app shape: `ModalHost`, `Theme.auto`, a `ScrollList` view
12
+ - [`file_browser.rb`](file_browser.rb) — a full app: two panes, fuzzy finder, commands, mouse
13
+
14
+ ## Feature showcases
15
+
16
+ Dip into these per feature:
17
+
18
+ - [`widgets.rb`](widgets.rb) — the built-in modal widgets
19
+ - [`form.rb`](form.rb) — composing text inputs
20
+ - [`paint.rb`](paint.rb) — mouse drawing and a custom modal
21
+ - [`csv_viewer.rb`](csv_viewer.rb) — `Widget::Table` over real data, with column scrolling
22
+ - [`downloads.rb`](downloads.rb) — a ticking dashboard (`Table` + `Spinner` + `Progress`)
23
+ - [`log_viewer.rb`](log_viewer.rb) — tabbed tail-f log viewer (`Tabs` + `TextView`)
24
+ - [`breakout.rb`](breakout.rb) — a real-time game (60 fps ticks, float physics)
25
+ - [`life.rb`](life.rb) — full-screen updates every frame (the N6 stress test)
26
+
27
+ A new example earns its place only by demonstrating something none of the
28
+ existing ones do; extending a showcase beats adding a near-duplicate.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Breakout: a tick-driven arcade sample. A ball (Float position, so it can move
5
+ # at shallow angles) bounces off the walls, a paddle, and five rows of colored
6
+ # bricks — where it hits the paddle decides the rebound angle, terminal cells
7
+ # are tall so the horizontal speed runs about twice the vertical, and the whole
8
+ # simulation is deterministic (no rand: the serve angle is fixed, so a test can
9
+ # predict the trajectory). Bricks are background-colored spaces, the ball "o",
10
+ # the paddle "=" (ASCII-only glyphs, N7). Resize rebuilds the field and returns
11
+ # the ball to the paddle, keeping score and lives (simple over clever).
12
+ #
13
+ # ruby examples/breakout.rb
14
+ #
15
+ # Mouse: press/drag to place the paddle under the pointer.
16
+ # Keys: left/right (or h/l) move, Space serve / restart, q (or Ctrl-C) quit.
17
+
18
+ require_relative "../lib/tui_tui"
19
+
20
+ module BreakoutSample
21
+ BAR = TuiTui::Style.new(attrs: [:reverse])
22
+ # Reverse video fills the ball's whole cell and flips against the terminal
23
+ # background, so it stays high-contrast on dark and light themes alike.
24
+ BALL = TuiTui::Style.new(attrs: [:reverse, :bold])
25
+ # Background-filled like the bricks, so the paddle reads as a solid bar.
26
+ PADDLE = TuiTui::Style.new(bg: :bright_cyan)
27
+ MSG = TuiTui::Style.new(fg: :bright_yellow, attrs: [:bold])
28
+ # Raw domain colors, one per brick row — game colors, not Theme roles.
29
+ BRICK_ROWS = %i[red yellow green cyan magenta].map { |c| TuiTui::Style.new(bg: c) }.freeze
30
+ BRICK_W = 4 # cells are ~2x taller than wide, so a 4x1 brick reads as a block
31
+ PADDLE_W = 10
32
+ # Cells per key press (and per key-repeat while held).
33
+ PADDLE_STEP = 4
34
+ # Vertical cells per tick; horizontal speed scales off this (cell aspect
35
+ # ratio). Raise it (or lower the Runtime tick below) for a faster game.
36
+ # Kept small enough that even the sharpest rebound moves less than one cell
37
+ # per frame, so the ball visits every column instead of skipping.
38
+ SPEED = 0.28
39
+ # Sharpest paddle rebound, as a multiple of SPEED (0.98 cells/frame max).
40
+ MAX_ANGLE = 3.5
41
+
42
+ class Breakout
43
+ def initialize(rows: 24, cols: 80)
44
+ @score = 0
45
+ @lives = 3
46
+ layout(rows, cols)
47
+ end
48
+
49
+ # Only the flying ball needs the timer; serving and game-over screens idle.
50
+ def wants_tick? = @phase == :play
51
+
52
+ def update(event)
53
+ case event
54
+ in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then :quit
55
+ in TuiTui::KeyEvent(key: :left | "h") then move_paddle(-PADDLE_STEP)
56
+ in TuiTui::KeyEvent(key: :right | "l") then move_paddle(PADDLE_STEP)
57
+ in TuiTui::KeyEvent(key: " ") then space
58
+ in TuiTui::MouseEvent(action: :press | :drag, col:) then follow(col)
59
+ in TuiTui::ResizeEvent(size:) then layout(size.rows, size.cols)
60
+ in TuiTui::TickEvent then step
61
+ else self
62
+ end
63
+ end
64
+
65
+ def view(ctx)
66
+ layout(ctx.rows, ctx.cols) if ctx.rows != @view_rows || ctx.cols != @view_cols
67
+ canvas = ctx.canvas
68
+ header, field = TuiTui::Rect.of(ctx).split_rows(1, :rest)
69
+ draw_header(canvas, header)
70
+ canvas.frame(field) if field.rows >= 2 && field.cols >= 2
71
+ draw_bricks(canvas, field)
72
+ draw_paddle(canvas, field)
73
+ draw_ball(canvas, field)
74
+ draw_message(canvas, field)
75
+ canvas
76
+ end
77
+
78
+ private
79
+
80
+ # Game coordinates live in the field's interior: (1,1)..(@rows,@cols),
81
+ # header and frame excluded. Rebuilt whenever the terminal size changes.
82
+ def layout(rows, cols)
83
+ @view_rows = rows
84
+ @view_cols = cols
85
+ @rows = [rows - 3, 1].max # 1 header row + 2 frame rows
86
+ @cols = [cols - 2, 1].max
87
+ @paddle_w = [PADDLE_W, @cols].min
88
+ @paddle = [((@cols - @paddle_w) / 2) + 1, 1].max
89
+ @bricks = {} # [row, index] => Style
90
+ BRICK_ROWS.each_with_index do |style, i|
91
+ row = 2 + i
92
+ next if row > @rows - 2 # leave room between bricks and paddle
93
+
94
+ (@cols / BRICK_W).times { |b| @bricks[[row, b]] = style }
95
+ end
96
+ serve
97
+ self
98
+ end
99
+
100
+ # Ball waits on the paddle. The launch angle is fixed (dx twice dy — cell
101
+ # aspect ratio), so the whole game is deterministic.
102
+ def serve
103
+ @phase = :serve
104
+ @bx = @paddle + ((@paddle_w - 1) / 2.0)
105
+ @by = [@rows - 1, 1].max.to_f
106
+ @dx = SPEED * 2
107
+ @dy = -SPEED
108
+ end
109
+
110
+ def space
111
+ case @phase
112
+ when :serve then @phase = :play
113
+ when :over, :clear then restart
114
+ end
115
+ self
116
+ end
117
+
118
+ def restart
119
+ @score = 0
120
+ @lives = 3
121
+ layout(@view_rows, @view_cols)
122
+ end
123
+
124
+ def move_paddle(delta)
125
+ @paddle = (@paddle + delta).clamp(1, [@cols - @paddle_w + 1, 1].max)
126
+ @bx = @paddle + ((@paddle_w - 1) / 2.0) if @phase == :serve # ball rides along
127
+ self
128
+ end
129
+
130
+ # Center the paddle under the pointer (screen col -> interior col is -1).
131
+ def follow(col) = move_paddle((col - 1 - (@paddle_w / 2)) - @paddle)
132
+
133
+ # One physics step: advance, bounce off walls, then bricks, then the paddle
134
+ # (or fall past it and lose a life). All-bricks-gone wins.
135
+ def step
136
+ return self unless @phase == :play
137
+
138
+ @bx += @dx
139
+ @by += @dy
140
+ bounce_walls
141
+ hit_brick
142
+ if @dy.positive? && @by >= @rows - 0.5 && over_paddle?
143
+ reflect_off_paddle
144
+ elsif @by > @rows
145
+ drop
146
+ end
147
+ @phase = :clear if @phase == :play && @bricks.empty?
148
+ self
149
+ end
150
+
151
+ def bounce_walls
152
+ if @bx < 1
153
+ @bx = 1.0
154
+ @dx = @dx.abs
155
+ elsif @bx > @cols
156
+ @bx = @cols.to_f
157
+ @dx = -@dx.abs
158
+ end
159
+ return unless @by < 1
160
+
161
+ @by = 1.0
162
+ @dy = @dy.abs
163
+ end
164
+
165
+ def hit_brick
166
+ return unless @bricks.delete([@by.round, (@bx.round - 1) / BRICK_W])
167
+
168
+ @score += 10
169
+ @dy = -@dy
170
+ end
171
+
172
+ def over_paddle? = @bx.round.between?(@paddle, @paddle + @paddle_w - 1)
173
+
174
+ # The rebound angle depends on where the ball meets the paddle: dead center
175
+ # goes straight up, the edges send it out at up to MAX_ANGLE x the
176
+ # vertical speed.
177
+ def reflect_off_paddle
178
+ offset = (@bx - (@paddle + ((@paddle_w - 1) / 2.0))) / [@paddle_w / 2.0, 1.0].max
179
+ @dx = (offset * MAX_ANGLE * SPEED).clamp(-MAX_ANGLE * SPEED, MAX_ANGLE * SPEED)
180
+ @dy = -SPEED
181
+ @by = [@rows - 1, 1].max.to_f
182
+ end
183
+
184
+ def drop
185
+ @lives -= 1
186
+ @lives.zero? ? @phase = :over : serve
187
+ end
188
+
189
+ def draw_header(canvas, header)
190
+ canvas.fill(header, BAR)
191
+ text = " score #{@score} lives #{@lives} arrows/h l=move Space=serve q=quit"
192
+ canvas.text(header.row, header.col, TuiTui::DisplayText.new(text).truncate(header.cols), BAR)
193
+ end
194
+
195
+ def draw_bricks(canvas, field)
196
+ @bricks.each do |(row, index), style|
197
+ left = (index * BRICK_W) + 1
198
+ canvas.text(field.row + row, field.col + left, " " * [BRICK_W, @cols - left + 1].min, style)
199
+ end
200
+ end
201
+
202
+ def draw_paddle(canvas, field)
203
+ canvas.text(field.row + @rows, field.col + @paddle, " " * @paddle_w, PADDLE)
204
+ end
205
+
206
+ def draw_ball(canvas, field)
207
+ return if %i[over clear].include?(@phase)
208
+
209
+ canvas.text(field.row + @by.round.clamp(1, @rows), field.col + @bx.round.clamp(1, @cols), "o", BALL)
210
+ end
211
+
212
+ def draw_message(canvas, field)
213
+ text = {serve: "Space to serve", over: "GAME OVER - Space to restart",
214
+ clear: "YOU WIN! - Space to restart"}[@phase]
215
+ return unless text
216
+
217
+ row = field.row + (field.rows / 2)
218
+ canvas.text(row, field.col + [(field.cols - text.length) / 2, 1].max, text, MSG)
219
+ end
220
+ end
221
+ end
222
+
223
+ if $PROGRAM_NAME == __FILE__
224
+ TuiTui::Runtime.new(BreakoutSample::Breakout.new).run(tick: 0.016)
225
+ end
data/examples/counter.rb CHANGED
@@ -2,8 +2,9 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # The smallest possible TuiTui app: a counter. It shows the whole contract and
5
- # nothing else — `view(size) -> Canvas` and `update(event) -> self | :quit` —
6
- # with no widgets and no layout.
5
+ # nothing else — `view(size) -> Canvas` and `update(event) -> app | :quit` —
6
+ # with no widgets and no layout. Events are `Data.define` values, so `update`
7
+ # dispatches with Ruby's pattern matching (`case`/`in`).
7
8
  #
8
9
  # ruby examples/counter.rb
9
10
  #
@@ -16,20 +17,18 @@ module CounterSample
16
17
  HINT = TuiTui::Style.new(attrs: [:dim])
17
18
 
18
19
  class Counter
19
- def initialize
20
- @count = 0
20
+ def initialize(count = 0)
21
+ @count = count
21
22
  end
22
23
 
23
24
  def update(event)
24
- return self unless event.is_a?(TuiTui::KeyEvent)
25
-
26
- case event.key
27
- when "q", TuiTui::KeyCode::CTRL_C then return :quit
28
- when "j", "+", :up then @count += 1
29
- when "k", "-", :down then @count -= 1
30
- when "r" then @count = 0
25
+ case event
26
+ in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then :quit
27
+ in TuiTui::KeyEvent(key: "j" | "+" | :up) then Counter.new(@count + 1)
28
+ in TuiTui::KeyEvent(key: "k" | "-" | :down) then Counter.new(@count - 1)
29
+ in TuiTui::KeyEvent(key: "r") then Counter.new(0)
30
+ else self
31
31
  end
32
- self
33
32
  end
34
33
 
35
34
  def view(size)
@@ -46,7 +46,8 @@ module CsvViewerSample
46
46
  def initialize(path)
47
47
  @path = path
48
48
  @header, @rows, @error = load_csv(path)
49
- @list = TuiTui::ScrollList.new(@rows.size)
49
+ @list = TuiTui::Widget::ScrollList.new(@rows.size)
50
+ @table = TuiTui::Widget::Table.new(@list)
50
51
  @col = 0
51
52
  @left_col = 0
52
53
  @page = 1
@@ -74,9 +75,7 @@ module CsvViewerSample
74
75
 
75
76
  def view(size)
76
77
  canvas = TuiTui::Canvas.blank(size)
77
- body, footer = split_footer(size)
78
- detail, status = footer.split_h(1)
79
- table = body
78
+ table, detail, status = TuiTui::Rect.of(size).split_rows(:rest, 1, 1)
80
79
 
81
80
  @page = [table.rows - 1, 1].max
82
81
  @list.ensure_visible(@page)
@@ -114,38 +113,36 @@ module CsvViewerSample
114
113
  def pad_row(row, width) = row + Array.new(width - row.size, "")
115
114
  def default_header(width) = Array.new(width) { |i| "column_#{i + 1}" }
116
115
 
117
- def split_footer(size)
118
- whole = TuiTui::Rect.new(row: 1, col: 1, rows: size.rows, cols: size.cols)
119
- return [whole, TuiTui::Rect.new(row: size.rows, col: 1, rows: 0, cols: size.cols)] if size.rows < 3
120
-
121
- whole.split_h(size.rows - 2)
122
- end
123
-
124
116
  def draw_table(canvas, rect)
125
117
  return if rect.rows <= 0 || rect.cols <= 0
126
118
 
127
- canvas.fill(TuiTui::Rect.new(row: rect.row, col: rect.col, rows: 1, cols: rect.cols), STYLE[:header])
128
- canvas.text(rect.row, rect.col, fit("#", ROW_NUMBER_WIDTH), STYLE[:header])
129
- each_visible_column(rect.cols - ROW_NUMBER_WIDTH) do |index, col, width|
130
- style = index == @col ? STYLE[:selected_cell] : STYLE[:header]
131
- canvas.text(rect.row, col, fit(@header[index], width), style)
132
- end
119
+ @table.draw(canvas, rect,
120
+ columns: visible_columns,
121
+ header_style: STYLE[:header],
122
+ highlight: STYLE[:selected_row]) { |row_index, selected| row_cells(row_index, selected) }
133
123
 
134
- if @rows.empty?
135
- canvas.text(rect.row + 1, rect.col + 1, "No rows", STYLE[:dim]) if rect.rows > 1
136
- return
124
+ canvas.text(rect.row + 1, rect.col + 1, "No rows", STYLE[:dim]) if @rows.empty? && rect.rows > 1
125
+ end
126
+
127
+ # The row-number column plus every data column from the left edge on;
128
+ # Table clips whatever overflows the rect. The selected column's label
129
+ # carries its own accent Span.
130
+ def visible_columns
131
+ data = (@left_col..last_col).map do |index|
132
+ label = index == @col ? TuiTui::Span[@header[index], STYLE[:selected_cell]] : @header[index]
133
+ [label, @widths[index]]
137
134
  end
135
+ [["#", ROW_NUMBER_WIDTH]] + data
136
+ end
138
137
 
139
- @list.each_visible([rect.rows - 1, 0].max) do |row_index, offset|
140
- row = rect.row + 1 + offset
141
- selected = row_index == @list.cursor
142
- canvas.fill(TuiTui::Rect.new(row: row, col: rect.col, rows: 1, cols: rect.cols), STYLE[:selected_row]) if selected
143
- canvas.text(row, rect.col, fit((row_index + 1).to_s, ROW_NUMBER_WIDTH), selected ? STYLE[:selected_row] : STYLE[:row_number])
144
- each_visible_column(rect.cols - ROW_NUMBER_WIDTH) do |index, col, width|
145
- style = selected && index == @col ? STYLE[:selected_cell] : (selected ? STYLE[:selected_row] : nil)
146
- canvas.text(row, col, fit(@rows[row_index][index], width), style)
147
- end
138
+ def row_cells(row_index, selected)
139
+ number = (row_index + 1).to_s
140
+ cells = [selected ? number : TuiTui::Span[number, STYLE[:row_number]]]
141
+ (@left_col..last_col).each do |index|
142
+ value = @rows[row_index][index]
143
+ cells << (selected && index == @col ? TuiTui::Span[value, STYLE[:selected_cell]] : value)
148
144
  end
145
+ cells
149
146
  end
150
147
 
151
148
  def draw_detail(canvas, rect)
@@ -174,19 +171,6 @@ module CsvViewerSample
174
171
  canvas.text(rect.row, rect.col + rect.cols - width, right, STYLE[:status])
175
172
  end
176
173
 
177
- def each_visible_column(available)
178
- return if available <= 0
179
-
180
- col = ROW_NUMBER_WIDTH + 1
181
- @left_col.upto(last_col) do |index|
182
- width = @widths[index]
183
- break if col + width - 1 > available + ROW_NUMBER_WIDTH
184
-
185
- yield index, col, width
186
- col += width
187
- end
188
- end
189
-
190
174
  def ensure_column_visible(available)
191
175
  return if available <= 0
192
176
 
@@ -200,7 +184,7 @@ module CsvViewerSample
200
184
  def first_hidden_column(available)
201
185
  used = 0
202
186
  @left_col.upto(last_col) do |index|
203
- used += @widths[index]
187
+ used += @widths[index] + 1 # +1 for Table's column gap
204
188
  return index if used > available
205
189
  end
206
190
  last_col + 1
@@ -216,15 +200,9 @@ module CsvViewerSample
216
200
  @header.each_index.map do |index|
217
201
  values = [@header[index], *@rows.first(200).map { |row| row[index] }]
218
202
  width = values.map { |value| TuiTui::DisplayText.new(value.to_s).width }.max || MIN_COLUMN_WIDTH
219
- (width + 2).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
203
+ (width + 1).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
220
204
  end
221
205
  end
222
-
223
- def fit(value, width)
224
- text = TuiTui::DisplayText.new(" #{value}")
225
- text = text.truncate(width - 1) if text.width >= width
226
- text.to_s + (" " * [width - text.width, 0].max)
227
- end
228
206
  end
229
207
  end
230
208