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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b33867fe04eb8d9683aad497f5695c4acd69d73f21c499f0b56f6bb9894ac59
|
|
4
|
+
data.tar.gz: 63803c2a7b40bcd5ea08a87646ad676566bbfdd25d5dc60003260d9ea65073ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`).
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
data/examples/README.md
ADDED
|
@@ -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) ->
|
|
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 =
|
|
20
|
+
def initialize(count = 0)
|
|
21
|
+
@count = count
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def update(event)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
data/examples/csv_viewer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 +
|
|
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
|
|