tuile 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e96464e067ccbb78bd11cf6639b53dc4f53de48519f7e47143a594b520bb99c
4
- data.tar.gz: 668bd3ac1b4130919949dce95867cccfa89dac387b6f164a2c7ea027cf04c1da
3
+ metadata.gz: 23a6288632a551240d224975faee1f71d477bb1465126d70915950f67c187650
4
+ data.tar.gz: 33e26892d2a625e85d5d2f2fd058a04080ef12ea38825cb2f9727a346f36c6a2
5
5
  SHA512:
6
- metadata.gz: 6e9e20049c86bab8649b3d53604a61438f27608ae62d009c6a556fe76b146e186072c46a4c5036173c3b40197d8648dee855dce401d4536060f269e159a40c98
7
- data.tar.gz: 2ddccc6f2d1664935ba7c64b523f09b3a1ba9a57c7c356620beef39fd623b4a76a60d7fd1fda109aa11e9a5693a911cc1736d0f07dc99aad507008726d3fd301
6
+ metadata.gz: 233960b007d8c81281e6726e605ab48e9bdc389399b796a4d9a19314e1be2bfab019a787ddc6ab8b421221010cb9402fdc102737fef085aaa326641b76275eed
7
+ data.tar.gz: 68b2425566e2a2586d686699df135a4a7fce835b0f2ca5f090204b3b82f6d3b6d7813375f9129e5256eea627be8df72e667694869d4ba475de55221c0db5ae8e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-20
4
+
5
+ - Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
6
+ - Add `Keys::CTRL_A..CTRL_Z` constants and `Keys.printable?` (extracted from `TextField`/`TextArea`/`Screen`).
7
+ - Extract `Component::TextInput` as the shared base of `TextField` and `TextArea`; add `#empty?`.
8
+ - `TextField`/`TextArea`: default `on_escape` to clear focus.
9
+ - `Screen#run_event_loop` accepts `capture_mouse:` (default `true`); pass `false` to skip xterm mouse tracking so the terminal's native select-to-copy keeps working.
10
+ - `StyledString`: add `#with_fg`, mirroring `#with_bg`.
11
+ - `Component::TextView`: add `#<<`, `#add_line`, `#empty?`, and `#remove_last_n_lines` for streaming-tail retraction.
12
+ - `MouseEvent`: map buttons 66/67 to `:scroll_left`/`:scroll_right`.
13
+ - `Component::LogWindow`: extract `#log` helper.
14
+ - `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
15
+ - Document `Component#repaint`'s attached-only call contract.
16
+ - Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
17
+ - **Breaking:** `Component::TextView#append` is now verbatim — chunks are concatenated onto the current last hard line, embedded `\n` becomes hard breaks, no implicit newline is inserted. Designed for streaming use (e.g. an LLM chat window feeding partial messages straight in). Aliased as `<<` for chainability. The old "add a new entry" behavior is now `Component::TextView#add_line`.
18
+ - **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
19
+ - Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
20
+ - Fix: `Popup` recomputes size from content on every `#open`.
21
+ - Fix: `Keys.getkey` reads 5 trailing bytes after ESC, not 6.
22
+ - Fix: `Component::List#add_line` rejects `nil`.
23
+
24
+ ## [0.3.0] - 2026-05-18
25
+
26
+ - Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
27
+ - Add `Tuile::StyledString` for span-modeled ANSI styling, with `#wrap` (span-preserving word wrap), `#ellipsize` (width-bounded truncation), `#with_bg`, and an `EMPTY` shared instance.
28
+ - Model `Label`, `List`, and `TextView` text as `StyledString`; pre-pad clipped/physical lines.
29
+ - Extract `Tuile::Ansi` for shared ANSI helpers.
30
+ - `Window#scrollbar=` accepts any content that exposes `scrollbar_visibility=`.
31
+ - Document `TextView` in the README and `examples/sampler.rb`.
32
+ - Remove `Tuile::Wrap` (superseded by `StyledString#wrap`).
33
+ - Remove `Tuile::Truncate` (superseded by `StyledString#ellipsize`).
34
+
3
35
  ## [0.2.0] - 2026-05-15
4
36
 
5
37
  - Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
data/README.md CHANGED
@@ -79,7 +79,10 @@ Save it as `hello.rb` and run `ruby hello.rb`. Press `q` or `ESC` to exit.
79
79
 
80
80
  A larger demo lives in [`examples/file_commander.rb`](examples/file_commander.rb):
81
81
  a two-pane file browser with cursor navigation, header label, and a layout
82
- that follows terminal resize.
82
+ that follows terminal resize. For a tour of every shipped component, run
83
+ [`examples/sampler.rb`](examples/sampler.rb): a two-pane sampler where the
84
+ left pane lists demos and the right pane loads the highlighted one. Tab /
85
+ Shift+Tab move focus between the list and the demo's widgets.
83
86
 
84
87
  ## How it works
85
88
 
@@ -143,7 +146,7 @@ posts a size event, runs layout, and invalidates the entire tree. Components
143
146
  react by reassigning their child rectangles inside `rect=` — do not install
144
147
  your own WINCH handler.
145
148
 
146
- ### Focus and shortcuts
149
+ ### Focus and keyboard input
147
150
 
148
151
  `screen.focused = component` walks parent pointers up to the root, marks the
149
152
  whole chain `active?`, and deactivates everything else. Click-to-focus and
@@ -151,10 +154,86 @@ whole chain `active?`, and deactivates everything else. Click-to-focus and
151
154
  returns true, so clicking a `Label` inside a `Window` does not pull focus
152
155
  away from the window's content.
153
156
 
154
- `key_shortcut` is matched against the focused component's whole subtree
155
- *unless* the focused component owns the hardware cursor (e.g. a `TextField`
156
- the user is typing into) — that suppression is what lets text fields swallow
157
- printable keys without sibling shortcuts hijacking them.
157
+ When a key arrives, the screen dispatches it in this order — the first
158
+ mechanism that handles it wins:
159
+
160
+ 1. **Tab / Shift+Tab** advance focus through `tab_stop?` components in the
161
+ current modal scope (the topmost popup if one is open, otherwise the
162
+ tiled content). They are intercepted at the screen level before anything
163
+ else sees them, so a focused `TextField` cannot swallow them.
164
+
165
+ 2. **Global shortcuts** registered via `Screen#register_global_shortcut`.
166
+ These are app-level hotkeys for actions that don't belong to any
167
+ specific component — opening a log window, toggling help, etc.:
168
+
169
+ ```ruby
170
+ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
171
+ over_popups: true,
172
+ hint: "^L #{Rainbow('log').cadetblue}") do
173
+ log_popup.open
174
+ end
175
+ screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
176
+ ```
177
+
178
+ Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
179
+ arrows, F-keys); printable keys raise so they can't hijack typing into
180
+ a `TextField`. By default, the shortcut is suppressed while any popup
181
+ is open and the popup receives the key; pass `over_popups: true` to
182
+ pre-empt the popup.
183
+
184
+ Pass `hint:` to surface the shortcut in the status bar. It's a
185
+ preformatted string the caller fully owns (color it however the rest
186
+ of your app does). In the tiled case it appears right after `q quit`
187
+ and before the active window's hint; while a popup is open, only
188
+ `over_popups: true` hints show up, prepended before the popup's
189
+ `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
190
+
191
+ 3. **`Component#key_shortcut`** — a declarative hotkey attached to a
192
+ component. The framework walks the focused component's subtree for a
193
+ match and focuses the winner. Good fit for "press F to focus the filter
194
+ field" or one-key tab pickers. The lookup is suppressed while the
195
+ focused component owns the hardware cursor (e.g. a `TextField` the user
196
+ is typing into) so editing isn't interrupted:
197
+
198
+ ```ruby
199
+ filter_field.key_shortcut = "f"
200
+ ```
201
+
202
+ 4. **`Component#handle_key`** — override this on your own component when
203
+ it needs to react to keys directly (a list reacting to arrows, a custom
204
+ widget handling Enter, …). Return `true` to mark the key handled,
205
+ `false` to let the dispatcher keep walking. Call `super` to keep the
206
+ default `key_shortcut` subtree lookup; suppress it only when you
207
+ deliberately want this component to swallow everything:
208
+
209
+ ```ruby
210
+ class Toggle < Tuile::Component
211
+ def handle_key(key)
212
+ if key == " "
213
+ @on = !@on
214
+ invalidate
215
+ true
216
+ else
217
+ super
218
+ end
219
+ end
220
+ end
221
+ ```
222
+
223
+ If nothing handles the key and it's `q` or `ESC`, the event loop exits.
224
+
225
+ A component can advertise the keys it responds to by overriding
226
+ `keyboard_hint`. The status bar shows the active window's hint alongside
227
+ the global `q quit` prompt; while a popup is open, the popup's own hint
228
+ replaces it, prefixed with `q Close`:
229
+
230
+ ```ruby
231
+ class FilterWindow < Tuile::Component::Window
232
+ def keyboard_hint
233
+ "f #{Rainbow('filter').cadetblue} Enter #{Rainbow('open').cadetblue}"
234
+ end
235
+ end
236
+ ```
158
237
 
159
238
  ## Components
160
239
 
@@ -349,6 +428,62 @@ Tuile.logger = TTY::Logger.new # duck-typed, works directly
349
428
  Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
350
429
  ```
351
430
 
431
+ ## Testing
432
+
433
+ Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
434
+ screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
435
+ collects every string the framework "would have printed" into an array, and
436
+ uses a synchronous `FakeEventQueue` (submitted blocks run inline; posted
437
+ events are discarded). No terminal IO happens, so the TTY running the tests
438
+ is never painted over.
439
+
440
+ The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
441
+ this resets the singleton between examples, so state can't leak across
442
+ tests:
443
+
444
+ ```ruby
445
+ require "tuile"
446
+
447
+ module Tuile
448
+ describe Component::Label do
449
+ before { Screen.fake }
450
+ after { Screen.close }
451
+
452
+ it "renders text into its rect" do
453
+ label = Component::Label.new
454
+ label.rect = Rect.new(0, 0, 5, 1)
455
+ label.text = "hi"
456
+ label.repaint
457
+ assert_equal [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
458
+ end
459
+ end
460
+ end
461
+ ```
462
+
463
+ Key hooks:
464
+
465
+ - `Screen.instance.prints` — array of strings the screen would have written
466
+ to the terminal. Assert against it (or `.join`) for repaint output.
467
+ - `Screen.instance.repaint` — drive a repaint synchronously; production code
468
+ must not call this, but specs use it to flush the invalidated set after a
469
+ mutation.
470
+ - `Screen.instance.invalidated?(component)` / `invalidated_clear` — verify
471
+ that a mutation did (or did not) invalidate something. Setting a property
472
+ to its current value should typically *not* invalidate.
473
+ - `Screen.instance.clear` — drops accumulated `prints` without resetting
474
+ invalidation.
475
+
476
+ Because `FakeEventQueue#submit` runs the block immediately on the calling
477
+ thread, code paths that marshal work back via `screen.event_queue.submit { … }`
478
+ just work in tests. Posted events (`#post`) are dropped — if your test needs
479
+ to drive a real event loop, you are in system-test territory.
480
+
481
+ For end-to-end tests of a runnable script, spawn it in a pseudo-TTY with
482
+ `PTY.spawn`, wait for a known glyph to confirm the first paint landed, send
483
+ a key, and assert the exit status. `spec/examples/hello_world_spec.rb` is the
484
+ canonical template; PTY-based tests are Linux/macOS only since Ruby's stdlib
485
+ `PTY` isn't on Windows.
486
+
352
487
  ## Development
353
488
 
354
489
  After checking out the repo, run `bin/setup` to install dependencies. Then,
data/examples/sampler.rb CHANGED
@@ -66,6 +66,7 @@ module SamplerExample
66
66
  ["Label", :build_label],
67
67
  ["TextField", :build_text_field],
68
68
  ["TextArea", :build_text_area],
69
+ ["TextView", :build_text_view],
69
70
  ["Button", :build_buttons],
70
71
  ["List", :build_list],
71
72
  ["Layout", :build_layout],
@@ -133,6 +134,38 @@ module SamplerExample
133
134
  end
134
135
  end
135
136
 
137
+ def build_text_view
138
+ prompt = Tuile::Component::Label.new
139
+ prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
140
+ "Tab here, then: ↑↓ / jk scroll a line; PgUp/PgDn a page; Ctrl+U/D half a page; " \
141
+ "Home/End / g/G jump to the edges."
142
+ window = Tuile::Component::Window.new("Excerpt")
143
+ view = Tuile::Component::TextView.new
144
+ view.text = "#{Rainbow("Tuile").green} is a small component-oriented terminal-UI framework built on top of " \
145
+ "the TTY toolkit. Apps build a tree of Components under a singleton Screen; the screen runs " \
146
+ "an event loop, dispatches keys and mouse events, and repaints invalidated components in " \
147
+ "batch.\n\n" \
148
+ "The name is #{Rainbow("French").cyan} for #{Rainbow("\"roof tile\"").yellow} — small pieces " \
149
+ "that compose into a larger whole. This excerpt wraps to the viewer's current width; resize " \
150
+ "the terminal to see the wrap recompute, and scroll to see the rest.\n\n" \
151
+ "Components do not paint immediately. They call invalidate (which records them in the " \
152
+ "Screen's pending-repaint set); after an event-loop tick drains the queue, Screen#repaint " \
153
+ "walks the set, sorts by depth, and paints parents before children. Popups deliberately " \
154
+ "overdraw the tiled tree on top.\n\n" \
155
+ "All UI mutations must run on the thread that owns Screen#run_event_loop. Background work " \
156
+ "marshals back via screen.event_queue.submit { … }. Most UI methods check the lock and " \
157
+ "raise if you violate the contract; FakeScreen short-circuits the check so tests can mutate " \
158
+ "freely."
159
+ window.content = view
160
+ window.scrollbar = true
161
+ panel(prompt, window) do |r|
162
+ inner = inner_rect(r)
163
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
164
+ view_height = [inner.height - 5, 4].max
165
+ window.rect = Tuile::Rect.new(inner.left, inner.top + 4, inner.width, view_height)
166
+ end
167
+ end
168
+
136
169
  def build_buttons
137
170
  label = Tuile::Component::Label.new
138
171
  label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
data/lib/tuile/ansi.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # ANSI escape sequence constants. Tuile emits colors and text attributes
5
+ # via Rainbow, which produces **SGR** sequences ("Select Graphic
6
+ # Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
7
+ # `\e[0m` reset).
8
+ module Ansi
9
+ # SGR reset (`ESC [ 0 m`). Restores the terminal's default foreground,
10
+ # background, and text attributes.
11
+ # @return [String]
12
+ RESET = "\e[0m"
13
+ end
14
+ end
@@ -2,38 +2,66 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A label which shows static text. No word-wrapping; clips long lines.
5
+ # A label which shows static text. No word-wrapping; long lines are
6
+ # truncated with an ellipsis. Text is modeled as a {StyledString};
7
+ # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
8
+ # embedded ANSI is honored) or a {StyledString} directly. {#text}
9
+ # always returns the {StyledString}.
6
10
  class Label < Component
7
11
  def initialize
8
12
  super
9
- @lines = []
13
+ @text = StyledString::EMPTY
10
14
  @clipped_lines = []
15
+ @blank_line = ""
11
16
  end
12
17
 
13
- # @param text [String, nil] draws this text. May contain ANSI formatting.
14
- # Clipped automatically.
18
+ # @return [StyledString] the current text. Defaults to an empty
19
+ # {StyledString}.
20
+ attr_reader :text
21
+
22
+ # Replaces the text. A `String` is parsed via {StyledString.parse}
23
+ # (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
24
+ # coerced to an empty {StyledString}. Lines wider than {#rect} are
25
+ # truncated with an ellipsis at paint time.
26
+ # @param value [String, StyledString, nil]
15
27
  # @return [void]
16
- def text=(text)
17
- @lines = text.to_s.split("\n")
28
+ def text=(value)
29
+ new_text = StyledString.parse(value)
30
+ return if @text == new_text
31
+
32
+ @text = new_text
18
33
  @content_size = nil
19
- update_clipped_text
34
+ update_clipped_lines
35
+ invalidate
20
36
  end
21
37
 
22
- # @return [Size]
38
+ # @return [Size] longest hard-line's display width × number of hard
39
+ # lines. Reported on the *unclipped* text — sizing is intrinsic to
40
+ # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
23
41
  def content_size
24
- @content_size ||= begin
25
- width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
26
- Size.new(width, @lines.size)
27
- end
42
+ @content_size ||=
43
+ if @text.empty?
44
+ Size::ZERO
45
+ else
46
+ hard_lines = @text.lines
47
+ width = hard_lines.map(&:display_width).max || 0
48
+ Size.new(width, hard_lines.size)
49
+ end
28
50
  end
29
51
 
52
+ # Paints the text into {#rect}.
53
+ #
54
+ # Skips the {Component#repaint} default's auto-clear: every row is
55
+ # painted explicitly (with pre-padded blanks past the last line), so
56
+ # the "fully draw over your rect" contract is met without an upfront
57
+ # wipe.
30
58
  # @return [void]
31
59
  def repaint
32
- super
33
- height = rect.height.clamp(0, nil)
34
- lines_to_print = @clipped_lines.length.clamp(nil, height)
35
- (0..lines_to_print - 1).each do |index|
36
- screen.print TTY::Cursor.move_to(rect.left, rect.top + index), @clipped_lines[index]
60
+ return if rect.empty?
61
+
62
+ (0...rect.height).each do |row|
63
+ line = @clipped_lines[row] || @blank_line
64
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
37
65
  end
38
66
  end
39
67
 
@@ -42,21 +70,31 @@ module Tuile
42
70
  # @return [void]
43
71
  def on_width_changed
44
72
  super
45
- update_clipped_text
73
+ update_clipped_lines
46
74
  end
47
75
 
48
76
  private
49
77
 
78
+ # Recomputes {@clipped_lines} for the current text and rect width.
79
+ # Each line is ellipsized to fit, padded with trailing spaces out to
80
+ # the full width, and pre-rendered to ANSI so {#repaint} is just a
81
+ # lookup + screen.print per row. {@blank_line} covers rows past the
82
+ # last text line.
50
83
  # @return [void]
51
- def update_clipped_text
52
- len = rect.width.clamp(0, nil)
53
- clipped = @lines.map do |line|
54
- Truncate.truncate(line, length: len)
55
- end
56
- return if @clipped_lines == clipped
84
+ def update_clipped_lines
85
+ width = rect.width.clamp(0, nil)
86
+ @blank_line = " " * width
87
+ @clipped_lines = @text.lines.map { |line| pad_to(line.ellipsize(width), width).to_ansi }
88
+ end
57
89
 
58
- @clipped_lines = clipped
59
- invalidate
90
+ # @param line [StyledString]
91
+ # @param width [Integer]
92
+ # @return [StyledString]
93
+ def pad_to(line, width)
94
+ diff = width - line.display_width
95
+ return line if diff <= 0
96
+
97
+ line + StyledString.plain(" " * diff)
60
98
  end
61
99
  end
62
100
  end