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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +10 -10
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +320 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +192 -63
- data/lib/tuile/component/text_area.rb +376 -0
- data/lib/tuile/component/text_field.rb +46 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +13 -5
- data/lib/tuile/component.rb +53 -5
- data/lib/tuile/event_queue.rb +14 -1
- data/lib/tuile/keys.rb +24 -4
- data/lib/tuile/screen.rb +127 -39
- data/lib/tuile/screen_pane.rb +29 -7
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +958 -53
- metadata +9 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
|
|
4
|
+
data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
41
|
+
gem install tuile
|
|
47
42
|
```
|
|
48
43
|
|
|
49
|
-
|
|
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
|
|
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
|
|
data/examples/file_commander.rb
CHANGED
|
@@ -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}"
|
data/examples/sampler.rb
ADDED
|
@@ -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;
|
|
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
|
-
@
|
|
13
|
+
@text = StyledString::EMPTY
|
|
10
14
|
@clipped_lines = []
|
|
15
|
+
@blank_line = ""
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
# @
|
|
14
|
-
#
|
|
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=(
|
|
17
|
-
|
|
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
|
-
|
|
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 ||=
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top +
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|