tuile 0.1.0 → 0.3.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: 246774aae8809b045b95adef86263642f561d726cb907162101cef1c94135142
4
- data.tar.gz: d6858c0ece1e461a5ff767572ac1fd6255eb338a84caff1b12d0a0db66aec6b0
3
+ metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
4
+ data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
5
5
  SHA512:
6
- metadata.gz: 97ecd56ae772b96c8944d78245ee628f56dbcf4a4c0a955319bc8d1bf630132dd6b23ec6bb23899a97f45b6b4b92b3686fd548c056bc9eb107caa06bcaf97a89
7
- data.tar.gz: 6b0f6c0f887ede7a2e7aa3309241b3b39844f5a2819f92fafdee52b1be4e88a2c9fc5a32f18ddcc3e0a671c0dedb4724de833da75d4c391a62619b8d4be58136
6
+ metadata.gz: 23c69343d8a0cc87143b12cd1a4a9a11862c770debf9c5381cabfcb59b55786f8be143ef362581e0a79bb1dc7f9ce512f47d755f603cb486bf4058b3487d4399
7
+ data.tar.gz: '0974d322289b63b43bdd498e172f446d7cc56df656645b8e6826fed388154dda4a287110819d305572dbbf1c1ee23011fc4e0cb208f6459d67535e2df5c1d2b1'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-18
4
+
5
+ - Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
6
+ - 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.
7
+ - Model `Label`, `List`, and `TextView` text as `StyledString`; pre-pad clipped/physical lines.
8
+ - Extract `Tuile::Ansi` for shared ANSI helpers.
9
+ - `Window#scrollbar=` accepts any content that exposes `scrollbar_visibility=`.
10
+ - Document `TextView` in the README and `examples/sampler.rb`.
11
+ - Remove `Tuile::Wrap` (superseded by `StyledString#wrap`).
12
+ - Remove `Tuile::Truncate` (superseded by `StyledString#ellipsize`).
13
+
14
+ ## [0.2.0] - 2026-05-15
15
+
16
+ - Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
17
+ - Add `Component::Button`.
18
+ - Add Tab / Shift+Tab focus cycling.
19
+ - Add Ctrl+arrow word navigation to `Component::TextField`.
20
+ - Add `Component::List#on_cursor_changed`.
21
+ - Add `examples/sampler.rb`.
22
+ - Paint `TextField` with a colored background.
23
+ - Buffer `Screen#print` into a per-frame buffer during repaint, and release it on exception.
24
+ - Join the key thread after killing it in `run_loop`'s ensure block.
25
+ - Auto-clear gappy children in `Component#repaint`.
26
+ - Inline a minimal truncation helper and drop the `strings-truncation` dependency.
27
+ - Lower the Ruby floor to 3.4; pin CI head to 4.0; fix Ruby 3.4 compatibility.
28
+ - Bump `minitest` to 6.0.
29
+ - Document `TextField` SGR constants; refresh `sig/tuile.rbs`.
30
+
3
31
  ## [0.1.0] - 2026-05-02
4
32
 
5
33
  - Initial release
data/README.md CHANGED
@@ -28,31 +28,28 @@ Ruby that we are aware of.
28
28
 
29
29
  ## Installation
30
30
 
31
- > **Note:** the gem name on RubyGems is being finalised. Until release, install
32
- > from git (see below). Replace
33
- > `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with
34
- > the gem name once published.
35
-
36
31
  Install the gem and add it to the application's Gemfile by executing:
37
32
 
38
33
  ```bash
39
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
34
+ bundle add tuile
40
35
  ```
41
36
 
42
37
  If bundler is not being used to manage dependencies, install the gem by
43
38
  executing:
44
39
 
45
40
  ```bash
46
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
41
+ gem install tuile
47
42
  ```
48
43
 
49
- Until then, point your `Gemfile` at git:
44
+ Or pin to git directly:
50
45
 
51
46
  ```ruby
52
47
  gem "tuile", git: "https://github.com/mvysny/tuile.git"
53
48
  ```
54
49
 
55
- Tuile requires Ruby 4.0+.
50
+ Tuile requires Ruby 3.4+.
51
+
52
+ API documentation: <https://rubydoc.info/gems/tuile>.
56
53
 
57
54
  ## Hello world
58
55
 
@@ -82,7 +79,10 @@ Save it as `hello.rb` and run `ruby hello.rb`. Press `q` or `ESC` to exit.
82
79
 
83
80
  A larger demo lives in [`examples/file_commander.rb`](examples/file_commander.rb):
84
81
  a two-pane file browser with cursor navigation, header label, and a layout
85
- 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.
86
86
 
87
87
  ## How it works
88
88
 
@@ -156,22 +156,8 @@ module FileCommanderExample
156
156
  rect.width - half, body_height)
157
157
  end
158
158
 
159
- def handle_key(key)
160
- if key == "\t"
161
- toggle_focus
162
- true
163
- else
164
- super
165
- end
166
- end
167
-
168
159
  private
169
160
 
170
- def toggle_focus
171
- target = @left_window.active? ? @right_window : @left_window
172
- screen.focused = target
173
- end
174
-
175
161
  def refresh_header
176
162
  active_list = @left_list.active? ? @left_list : @right_list
177
163
  @header.text = " #{active_list.cwd}"
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Tuile sampler. Two-pane demo app showcasing the components shipped with
5
+ # the framework. The left pane is a navigation list; moving the cursor
6
+ # loads the highlighted demo into the right pane. Tab / Shift+Tab move
7
+ # focus between the list and the demo's widgets.
8
+ #
9
+ # Run from the gem root:
10
+ # bundle exec ruby -Ilib examples/sampler.rb
11
+ #
12
+ # Keys (global): q or ESC to quit.
13
+
14
+ require "tuile"
15
+
16
+ module SamplerExample
17
+ # Sampler-local container: a {Tuile::Component::Layout::Absolute} that
18
+ # runs a caller-supplied block on `rect=` to position its children.
19
+ # Sampler demos sometimes have a 1-row Label sitting in a tall pane,
20
+ # but the stock layout's auto-clear already handles those gaps for us
21
+ # — Panel just needs the rect-callback to drive child positioning.
22
+ class Panel < Tuile::Component::Layout::Absolute
23
+ def initialize(&layout_block)
24
+ super()
25
+ @layout_block = layout_block
26
+ end
27
+
28
+ def rect=(new_rect)
29
+ super
30
+ @layout_block&.call(rect) unless rect.empty?
31
+ end
32
+ end
33
+
34
+ # Top-level sampler component. Splits the screen into a left entry list
35
+ # and a right demo pane; each `load_entry` rebuilds the demo from
36
+ # scratch so it always starts in a clean state.
37
+ class Sampler < Tuile::Component::Layout::Absolute
38
+ def initialize
39
+ super()
40
+ @entry_list = build_entry_list
41
+ @left_window = Tuile::Component::Window.new("Components").tap { it.content = @entry_list }
42
+ @right_window = Tuile::Component::Window.new
43
+ add(@left_window)
44
+ add(@right_window)
45
+ load_entry(0)
46
+ end
47
+
48
+ attr_reader :left_window, :right_window, :entry_list
49
+
50
+ def rect=(new_rect)
51
+ super
52
+ return if rect.empty?
53
+
54
+ list_width = (rect.width / 3).clamp(20, 40)
55
+ @left_window.rect = Tuile::Rect.new(rect.left, rect.top, list_width, rect.height)
56
+ @right_window.rect = Tuile::Rect.new(rect.left + list_width, rect.top,
57
+ rect.width - list_width, rect.height)
58
+ end
59
+
60
+ private
61
+
62
+ # Ordered list of demo entries: `[caption, builder_method]`. The
63
+ # builder runs at selection time, so every load gets a fresh component
64
+ # tree (an empty TextField, an un-clicked Button, etc.).
65
+ ENTRIES = [
66
+ ["Label", :build_label],
67
+ ["TextField", :build_text_field],
68
+ ["TextArea", :build_text_area],
69
+ ["TextView", :build_text_view],
70
+ ["Button", :build_buttons],
71
+ ["List", :build_list],
72
+ ["Layout", :build_layout],
73
+ ["Popup", :build_popup_launcher],
74
+ ["InfoWindow", :build_info_launcher],
75
+ ["PickerWindow", :build_picker_launcher],
76
+ ["LogWindow", :build_log_window],
77
+ ["Focus & Tab", :build_focus_demo]
78
+ ].freeze
79
+
80
+ def build_entry_list
81
+ list = Tuile::Component::List.new
82
+ list.cursor = Tuile::Component::List::Cursor.new
83
+ list.lines = ENTRIES.map(&:first)
84
+ list.on_cursor_changed = ->(idx, _line) { load_entry(idx) if idx >= 0 }
85
+ list
86
+ end
87
+
88
+ def load_entry(idx)
89
+ caption, builder = ENTRIES[idx]
90
+ @right_window.caption = caption
91
+ @right_window.content = send(builder)
92
+ end
93
+
94
+ # --- Tileable demos ----------------------------------------------------
95
+
96
+ def build_label
97
+ label = Tuile::Component::Label.new
98
+ label.text = "Label paints static text in its rect.\n" \
99
+ "Multiple lines split on \\n.\n" \
100
+ "Long lines are clipped to the rect width.\n\n" \
101
+ "Rainbow formatting works too:\n" \
102
+ " #{Rainbow("* red").red}\n" \
103
+ " #{Rainbow("* green").green}\n" \
104
+ " #{Rainbow("* blue").blue}"
105
+ label
106
+ end
107
+
108
+ def build_text_field
109
+ prompt = Tuile::Component::Label.new
110
+ prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
111
+ field = Tuile::Component::TextField.new
112
+ panel(prompt, field) do |r|
113
+ inner = inner_rect(r)
114
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 1)
115
+ field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
116
+ end
117
+ end
118
+
119
+ def build_text_area
120
+ prompt = Tuile::Component::Label.new
121
+ prompt.text = "Multi-line input. Type to see word wrap; Enter inserts a newline.\n" \
122
+ "Arrows move the caret; Ctrl+Left/Right jump by word; " \
123
+ "Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
124
+ "Overflowing rows scroll vertically to keep the caret visible."
125
+ area = Tuile::Component::TextArea.new
126
+ area.text = "The quick brown fox jumps over the lazy dog. " \
127
+ "Edit me — the text wraps to the area's width and scrolls vertically " \
128
+ "once the cursor leaves the visible rows."
129
+ panel(prompt, area) do |r|
130
+ inner = inner_rect(r)
131
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
132
+ area_height = [inner.height - 6, 4].max
133
+ area.rect = Tuile::Rect.new(inner.left, inner.top + 5, inner.width, area_height)
134
+ end
135
+ end
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
+
169
+ def build_buttons
170
+ label = Tuile::Component::Label.new
171
+ label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
172
+ counters = { ok: 0, cancel: 0 }
173
+ result = Tuile::Component::Label.new
174
+ refresh = -> { result.text = "Clicks: OK=#{counters[:ok]} Cancel=#{counters[:cancel]}" }
175
+ refresh.call
176
+ ok = Tuile::Component::Button.new("OK") do
177
+ counters[:ok] += 1
178
+ refresh.call
179
+ end
180
+ cancel = Tuile::Component::Button.new("Cancel") do
181
+ counters[:cancel] += 1
182
+ refresh.call
183
+ end
184
+ panel(label, ok, cancel, result) do |r|
185
+ inner = inner_rect(r)
186
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
187
+ ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, ok.content_size.width, 1)
188
+ cancel.rect = Tuile::Rect.new(inner.left + ok.content_size.width + 2, inner.top + 4,
189
+ cancel.content_size.width, 1)
190
+ result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
191
+ end
192
+ end
193
+
194
+ def build_list
195
+ list = Tuile::Component::List.new
196
+ list.cursor = Tuile::Component::List::Cursor.new
197
+ list.lines = (1..40).map { |i| "Item #{i}" }
198
+ list.scrollbar_visibility = :visible
199
+ list
200
+ end
201
+
202
+ def build_layout
203
+ left = Tuile::Component::Window.new("Left")
204
+ left.content = Tuile::Component::Label.new.tap { it.text = "Nested left window." }
205
+ right = Tuile::Component::Window.new("Right")
206
+ right.content = Tuile::Component::Label.new.tap { it.text = "Nested right window." }
207
+ panel(left, right) do |r|
208
+ half = r.width / 2
209
+ left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
210
+ right.rect = Tuile::Rect.new(r.left + half, r.top, r.width - half, r.height)
211
+ end
212
+ end
213
+
214
+ # --- Modal launchers ---------------------------------------------------
215
+
216
+ def build_popup_launcher
217
+ launcher(
218
+ "Popup is a modal overlay wrapping any Component.\n" \
219
+ "ESC or q closes it.",
220
+ "Open Popup"
221
+ ) do
222
+ list = Tuile::Component::List.new
223
+ list.lines = ["Hello", "from", "a Popup!", "", "Press ESC to close."]
224
+ Tuile::Component::Popup.new(content: list).open
225
+ end
226
+ end
227
+
228
+ def build_info_launcher
229
+ launcher(
230
+ "InfoWindow is a Window of read-only text lines, openable as a popup.",
231
+ "Open InfoWindow"
232
+ ) do
233
+ Tuile::Component::InfoWindow.open(
234
+ "Hello",
235
+ ["InfoWindow displays static text",
236
+ "inside a popup.",
237
+ "",
238
+ "Press ESC or q to close."]
239
+ )
240
+ end
241
+ end
242
+
243
+ def build_picker_launcher
244
+ launcher(
245
+ "PickerWindow asks the user to pick one option by a single keystroke.",
246
+ "Open PickerWindow"
247
+ ) do
248
+ Tuile::Component::PickerWindow.open(
249
+ "Pick a fruit",
250
+ [%w[a Apple], %w[b Banana], %w[c Cherry]]
251
+ ) { |key| Tuile.logger.info("Picked: #{key}") }
252
+ end
253
+ end
254
+
255
+ def build_log_window
256
+ log = Tuile::Component::LogWindow.new("Log")
257
+ log.content.add_lines([
258
+ "LogWindow is a Window wrapping an auto-scrolling List.",
259
+ "Lines are appended via #add_line / #add_lines.",
260
+ "Used with Logger::IO it captures arbitrary log output."
261
+ ])
262
+ log
263
+ end
264
+
265
+ # --- Cross-cutting -----------------------------------------------------
266
+
267
+ def build_focus_demo
268
+ label = Tuile::Component::Label.new
269
+ label.text = "Tab and Shift+Tab cycle focus through the tab stops below.\n" \
270
+ "The active button highlights its background; the field shows a caret."
271
+ a = Tuile::Component::Button.new("Button A")
272
+ b = Tuile::Component::Button.new("Button B")
273
+ field = Tuile::Component::TextField.new
274
+ panel(label, a, b, field) do |r|
275
+ inner = inner_rect(r)
276
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
277
+ a.rect = Tuile::Rect.new(inner.left, inner.top + 4, a.content_size.width, 1)
278
+ b.rect = Tuile::Rect.new(inner.left + a.content_size.width + 2, inner.top + 4,
279
+ b.content_size.width, 1)
280
+ field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
281
+ end
282
+ end
283
+
284
+ # --- Helpers -----------------------------------------------------------
285
+
286
+ def panel(*children, &layout_block)
287
+ p = Panel.new(&layout_block)
288
+ p.add(children)
289
+ p
290
+ end
291
+
292
+ def launcher(description, button_caption, &on_click)
293
+ label = Tuile::Component::Label.new
294
+ label.text = description
295
+ button = Tuile::Component::Button.new(button_caption, &on_click)
296
+ panel(label, button) do |r|
297
+ inner = inner_rect(r)
298
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
299
+ button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button.content_size.width, 1)
300
+ end
301
+ end
302
+
303
+ # Carves a 2-column padding out of the panel rect so the demo content
304
+ # doesn't run flush to the window border.
305
+ def inner_rect(rect)
306
+ pad = 2
307
+ Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
308
+ end
309
+ end
310
+ end
311
+
312
+ screen = Tuile::Screen.new
313
+ sampler = SamplerExample::Sampler.new
314
+ screen.content = sampler
315
+ sampler.entry_list.focus
316
+ begin
317
+ screen.run_event_loop
318
+ ensure
319
+ screen.close
320
+ end
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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A clickable button. Activated by Enter, Space, or a left mouse click;
6
+ # fires the {#on_click} callback. Renders as `[ caption ]` on a single
7
+ # row; the background is highlighted when the button is focused so the
8
+ # user can see which button is active.
9
+ #
10
+ # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
11
+ # the standard focus cycle. Click-to-focus also works via the inherited
12
+ # {Component#handle_mouse}.
13
+ #
14
+ # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
+ # show `[ caption ]`; {#content_size} reports that natural width.
16
+ class Button < Component
17
+ # @param caption [String] the button's label.
18
+ # @yield optional `on_click` callback; same as assigning {#on_click=}.
19
+ def initialize(caption = "", &on_click)
20
+ super()
21
+ @caption = caption.to_s
22
+ @on_click = on_click
23
+ end
24
+
25
+ # @return [String] the button's label.
26
+ attr_reader :caption
27
+
28
+ # Callback fired when the button is activated (Enter, Space, or
29
+ # left-click). The callable receives no arguments.
30
+ # @return [Proc, Method, nil] no-arg callable, or nil.
31
+ attr_accessor :on_click
32
+
33
+ # Sets a new caption and invalidates the button. No-op if unchanged.
34
+ # @param new_caption [String]
35
+ def caption=(new_caption)
36
+ new_caption = new_caption.to_s
37
+ return if @caption == new_caption
38
+
39
+ @caption = new_caption
40
+ invalidate
41
+ end
42
+
43
+ def focusable? = true
44
+
45
+ def tab_stop? = true
46
+
47
+ # @return [Size] natural width is `caption.length + 4` to fit
48
+ # `[ caption ]`; height is 1.
49
+ def content_size = Size.new(@caption.length + 4, 1)
50
+
51
+ # @param key [String]
52
+ # @return [Boolean]
53
+ def handle_key(key)
54
+ return false unless active?
55
+ return true if super
56
+
57
+ case key
58
+ when Keys::ENTER, " "
59
+ @on_click&.call
60
+ true
61
+ else
62
+ false
63
+ end
64
+ end
65
+
66
+ # @param event [MouseEvent]
67
+ # @return [void]
68
+ def handle_mouse(event)
69
+ super
70
+ return unless event.button == :left && rect.contains?(event.point)
71
+
72
+ @on_click&.call
73
+ end
74
+
75
+ # @return [void]
76
+ def repaint
77
+ super
78
+ return if rect.empty?
79
+
80
+ label = "[ #{@caption} ]"[0, rect.width]
81
+ styled = active? ? Rainbow(label).bg(:darkslategray) : label
82
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), styled
83
+ end
84
+ end
85
+ end
86
+ 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
- clear_background
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? || rect.left.negative? || rect.top.negative?
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
- Strings::Truncation.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