tuile 0.1.0 → 0.2.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 +17 -0
- data/README.md +6 -9
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +287 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +2 -2
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +47 -4
- data/lib/tuile/component/text_area.rb +378 -0
- data/lib/tuile/component/text_field.rb +49 -4
- data/lib/tuile/component/window.rb +11 -3
- 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/truncate.rb +83 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +363 -13
- metadata +7 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e96464e067ccbb78bd11cf6639b53dc4f53de48519f7e47143a594b520bb99c
|
|
4
|
+
data.tar.gz: 668bd3ac1b4130919949dce95867cccfa89dac387b6f164a2c7ea027cf04c1da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e9e20049c86bab8649b3d53604a61438f27608ae62d009c6a556fe76b146e186072c46a4c5036173c3b40197d8648dee855dce401d4536060f269e159a40c98
|
|
7
|
+
data.tar.gz: 2ddccc6f2d1664935ba7c64b523f09b3a1ba9a57c7c356620beef39fd623b4a76a60d7fd1fda109aa11e9a5693a911cc1736d0f07dc99aad507008726d3fd301
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-05-15
|
|
4
|
+
|
|
5
|
+
- Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
|
|
6
|
+
- Add `Component::Button`.
|
|
7
|
+
- Add Tab / Shift+Tab focus cycling.
|
|
8
|
+
- Add Ctrl+arrow word navigation to `Component::TextField`.
|
|
9
|
+
- Add `Component::List#on_cursor_changed`.
|
|
10
|
+
- Add `examples/sampler.rb`.
|
|
11
|
+
- Paint `TextField` with a colored background.
|
|
12
|
+
- Buffer `Screen#print` into a per-frame buffer during repaint, and release it on exception.
|
|
13
|
+
- Join the key thread after killing it in `run_loop`'s ensure block.
|
|
14
|
+
- Auto-clear gappy children in `Component#repaint`.
|
|
15
|
+
- Inline a minimal truncation helper and drop the `strings-truncation` dependency.
|
|
16
|
+
- Lower the Ruby floor to 3.4; pin CI head to 4.0; fix Ruby 3.4 compatibility.
|
|
17
|
+
- Bump `minitest` to 6.0.
|
|
18
|
+
- Document `TextField` SGR constants; refresh `sig/tuile.rbs`.
|
|
19
|
+
|
|
3
20
|
## [0.1.0] - 2026-05-02
|
|
4
21
|
|
|
5
22
|
- 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
|
|
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,287 @@
|
|
|
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
|
+
["Button", :build_buttons],
|
|
70
|
+
["List", :build_list],
|
|
71
|
+
["Layout", :build_layout],
|
|
72
|
+
["Popup", :build_popup_launcher],
|
|
73
|
+
["InfoWindow", :build_info_launcher],
|
|
74
|
+
["PickerWindow", :build_picker_launcher],
|
|
75
|
+
["LogWindow", :build_log_window],
|
|
76
|
+
["Focus & Tab", :build_focus_demo]
|
|
77
|
+
].freeze
|
|
78
|
+
|
|
79
|
+
def build_entry_list
|
|
80
|
+
list = Tuile::Component::List.new
|
|
81
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
82
|
+
list.lines = ENTRIES.map(&:first)
|
|
83
|
+
list.on_cursor_changed = ->(idx, _line) { load_entry(idx) if idx >= 0 }
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_entry(idx)
|
|
88
|
+
caption, builder = ENTRIES[idx]
|
|
89
|
+
@right_window.caption = caption
|
|
90
|
+
@right_window.content = send(builder)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Tileable demos ----------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def build_label
|
|
96
|
+
label = Tuile::Component::Label.new
|
|
97
|
+
label.text = "Label paints static text in its rect.\n" \
|
|
98
|
+
"Multiple lines split on \\n.\n" \
|
|
99
|
+
"Long lines are clipped to the rect width.\n\n" \
|
|
100
|
+
"Rainbow formatting works too:\n" \
|
|
101
|
+
" #{Rainbow("* red").red}\n" \
|
|
102
|
+
" #{Rainbow("* green").green}\n" \
|
|
103
|
+
" #{Rainbow("* blue").blue}"
|
|
104
|
+
label
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_text_field
|
|
108
|
+
prompt = Tuile::Component::Label.new
|
|
109
|
+
prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
|
|
110
|
+
field = Tuile::Component::TextField.new
|
|
111
|
+
panel(prompt, field) do |r|
|
|
112
|
+
inner = inner_rect(r)
|
|
113
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 1)
|
|
114
|
+
field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_text_area
|
|
119
|
+
prompt = Tuile::Component::Label.new
|
|
120
|
+
prompt.text = "Multi-line input. Type to see word wrap; Enter inserts a newline.\n" \
|
|
121
|
+
"Arrows move the caret; Ctrl+Left/Right jump by word; " \
|
|
122
|
+
"Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
|
|
123
|
+
"Overflowing rows scroll vertically to keep the caret visible."
|
|
124
|
+
area = Tuile::Component::TextArea.new
|
|
125
|
+
area.text = "The quick brown fox jumps over the lazy dog. " \
|
|
126
|
+
"Edit me — the text wraps to the area's width and scrolls vertically " \
|
|
127
|
+
"once the cursor leaves the visible rows."
|
|
128
|
+
panel(prompt, area) do |r|
|
|
129
|
+
inner = inner_rect(r)
|
|
130
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
131
|
+
area_height = [inner.height - 6, 4].max
|
|
132
|
+
area.rect = Tuile::Rect.new(inner.left, inner.top + 5, inner.width, area_height)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_buttons
|
|
137
|
+
label = Tuile::Component::Label.new
|
|
138
|
+
label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
|
|
139
|
+
counters = { ok: 0, cancel: 0 }
|
|
140
|
+
result = Tuile::Component::Label.new
|
|
141
|
+
refresh = -> { result.text = "Clicks: OK=#{counters[:ok]} Cancel=#{counters[:cancel]}" }
|
|
142
|
+
refresh.call
|
|
143
|
+
ok = Tuile::Component::Button.new("OK") do
|
|
144
|
+
counters[:ok] += 1
|
|
145
|
+
refresh.call
|
|
146
|
+
end
|
|
147
|
+
cancel = Tuile::Component::Button.new("Cancel") do
|
|
148
|
+
counters[:cancel] += 1
|
|
149
|
+
refresh.call
|
|
150
|
+
end
|
|
151
|
+
panel(label, ok, cancel, result) do |r|
|
|
152
|
+
inner = inner_rect(r)
|
|
153
|
+
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
154
|
+
ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, ok.content_size.width, 1)
|
|
155
|
+
cancel.rect = Tuile::Rect.new(inner.left + ok.content_size.width + 2, inner.top + 4,
|
|
156
|
+
cancel.content_size.width, 1)
|
|
157
|
+
result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_list
|
|
162
|
+
list = Tuile::Component::List.new
|
|
163
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
164
|
+
list.lines = (1..40).map { |i| "Item #{i}" }
|
|
165
|
+
list.scrollbar_visibility = :visible
|
|
166
|
+
list
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_layout
|
|
170
|
+
left = Tuile::Component::Window.new("Left")
|
|
171
|
+
left.content = Tuile::Component::Label.new.tap { it.text = "Nested left window." }
|
|
172
|
+
right = Tuile::Component::Window.new("Right")
|
|
173
|
+
right.content = Tuile::Component::Label.new.tap { it.text = "Nested right window." }
|
|
174
|
+
panel(left, right) do |r|
|
|
175
|
+
half = r.width / 2
|
|
176
|
+
left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
|
|
177
|
+
right.rect = Tuile::Rect.new(r.left + half, r.top, r.width - half, r.height)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- Modal launchers ---------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def build_popup_launcher
|
|
184
|
+
launcher(
|
|
185
|
+
"Popup is a modal overlay wrapping any Component.\n" \
|
|
186
|
+
"ESC or q closes it.",
|
|
187
|
+
"Open Popup"
|
|
188
|
+
) do
|
|
189
|
+
list = Tuile::Component::List.new
|
|
190
|
+
list.lines = ["Hello", "from", "a Popup!", "", "Press ESC to close."]
|
|
191
|
+
Tuile::Component::Popup.new(content: list).open
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def build_info_launcher
|
|
196
|
+
launcher(
|
|
197
|
+
"InfoWindow is a Window of read-only text lines, openable as a popup.",
|
|
198
|
+
"Open InfoWindow"
|
|
199
|
+
) do
|
|
200
|
+
Tuile::Component::InfoWindow.open(
|
|
201
|
+
"Hello",
|
|
202
|
+
["InfoWindow displays static text",
|
|
203
|
+
"inside a popup.",
|
|
204
|
+
"",
|
|
205
|
+
"Press ESC or q to close."]
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_picker_launcher
|
|
211
|
+
launcher(
|
|
212
|
+
"PickerWindow asks the user to pick one option by a single keystroke.",
|
|
213
|
+
"Open PickerWindow"
|
|
214
|
+
) do
|
|
215
|
+
Tuile::Component::PickerWindow.open(
|
|
216
|
+
"Pick a fruit",
|
|
217
|
+
[%w[a Apple], %w[b Banana], %w[c Cherry]]
|
|
218
|
+
) { |key| Tuile.logger.info("Picked: #{key}") }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def build_log_window
|
|
223
|
+
log = Tuile::Component::LogWindow.new("Log")
|
|
224
|
+
log.content.add_lines([
|
|
225
|
+
"LogWindow is a Window wrapping an auto-scrolling List.",
|
|
226
|
+
"Lines are appended via #add_line / #add_lines.",
|
|
227
|
+
"Used with Logger::IO it captures arbitrary log output."
|
|
228
|
+
])
|
|
229
|
+
log
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# --- Cross-cutting -----------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def build_focus_demo
|
|
235
|
+
label = Tuile::Component::Label.new
|
|
236
|
+
label.text = "Tab and Shift+Tab cycle focus through the tab stops below.\n" \
|
|
237
|
+
"The active button highlights its background; the field shows a caret."
|
|
238
|
+
a = Tuile::Component::Button.new("Button A")
|
|
239
|
+
b = Tuile::Component::Button.new("Button B")
|
|
240
|
+
field = Tuile::Component::TextField.new
|
|
241
|
+
panel(label, a, b, field) do |r|
|
|
242
|
+
inner = inner_rect(r)
|
|
243
|
+
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
|
|
244
|
+
a.rect = Tuile::Rect.new(inner.left, inner.top + 4, a.content_size.width, 1)
|
|
245
|
+
b.rect = Tuile::Rect.new(inner.left + a.content_size.width + 2, inner.top + 4,
|
|
246
|
+
b.content_size.width, 1)
|
|
247
|
+
field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# --- Helpers -----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def panel(*children, &layout_block)
|
|
254
|
+
p = Panel.new(&layout_block)
|
|
255
|
+
p.add(children)
|
|
256
|
+
p
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def launcher(description, button_caption, &on_click)
|
|
260
|
+
label = Tuile::Component::Label.new
|
|
261
|
+
label.text = description
|
|
262
|
+
button = Tuile::Component::Button.new(button_caption, &on_click)
|
|
263
|
+
panel(label, button) do |r|
|
|
264
|
+
inner = inner_rect(r)
|
|
265
|
+
label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
|
|
266
|
+
button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button.content_size.width, 1)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Carves a 2-column padding out of the panel rect so the demo content
|
|
271
|
+
# doesn't run flush to the window border.
|
|
272
|
+
def inner_rect(rect)
|
|
273
|
+
pad = 2
|
|
274
|
+
Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
screen = Tuile::Screen.new
|
|
280
|
+
sampler = SamplerExample::Sampler.new
|
|
281
|
+
screen.content = sampler
|
|
282
|
+
sampler.entry_list.focus
|
|
283
|
+
begin
|
|
284
|
+
screen.run_event_loop
|
|
285
|
+
ensure
|
|
286
|
+
screen.close
|
|
287
|
+
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
|
|
@@ -29,7 +29,7 @@ module Tuile
|
|
|
29
29
|
|
|
30
30
|
# @return [void]
|
|
31
31
|
def repaint
|
|
32
|
-
|
|
32
|
+
super
|
|
33
33
|
height = rect.height.clamp(0, nil)
|
|
34
34
|
lines_to_print = @clipped_lines.length.clamp(nil, height)
|
|
35
35
|
(0..lines_to_print - 1).each do |index|
|
|
@@ -51,7 +51,7 @@ module Tuile
|
|
|
51
51
|
def update_clipped_text
|
|
52
52
|
len = rect.width.clamp(0, nil)
|
|
53
53
|
clipped = @lines.map do |line|
|
|
54
|
-
|
|
54
|
+
Truncate.truncate(line, length: len)
|
|
55
55
|
end
|
|
56
56
|
return if @clipped_lines == clipped
|
|
57
57
|
|
|
@@ -5,9 +5,11 @@ module Tuile
|
|
|
5
5
|
# A layout doesn't paint anything by itself: its job is to position child
|
|
6
6
|
# components.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# Children that fully tile the layout's rect repaint themselves and
|
|
9
|
+
# cover everything; children that leave gaps (e.g. a form with widgets
|
|
10
|
+
# of varying widths) trigger {Component#repaint}'s default behavior —
|
|
11
|
+
# the background is cleared and children are re-invalidated so they
|
|
12
|
+
# paint over a clean surface.
|
|
11
13
|
class Layout < Component
|
|
12
14
|
def initialize
|
|
13
15
|
super
|
|
@@ -17,6 +19,16 @@ module Tuile
|
|
|
17
19
|
# @return [Array<Component>]
|
|
18
20
|
def children = @children.to_a
|
|
19
21
|
|
|
22
|
+
# Layouts are focusable containers — like {Window} and {Popup}, they
|
|
23
|
+
# don't accept input themselves but they need to participate in the
|
|
24
|
+
# {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
|
|
25
|
+
# {TextField} ends up focusing the field rather than parking focus on
|
|
26
|
+
# the popup. Layouts don't paint any visible chrome of their own
|
|
27
|
+
# (the auto-cleared background is just blank space), so this has no
|
|
28
|
+
# mouse-routing consequences — clicks on a gap area land back on the
|
|
29
|
+
# Layout itself and the on_focus cascade forwards to a tab stop.
|
|
30
|
+
def focusable? = true
|
|
31
|
+
|
|
20
32
|
# Adds a child component to this layout.
|
|
21
33
|
# @param child [Component, Array<Component>]
|
|
22
34
|
# @return [void]
|
|
@@ -53,11 +65,6 @@ module Tuile
|
|
|
53
65
|
Size.new(right - rect.left, bottom - rect.top)
|
|
54
66
|
end
|
|
55
67
|
|
|
56
|
-
# @return [void]
|
|
57
|
-
def repaint
|
|
58
|
-
clear_background if @children.empty?
|
|
59
|
-
end
|
|
60
|
-
|
|
61
68
|
# Dispatches the event to the child under the mouse cursor.
|
|
62
69
|
# @param event [MouseEvent]
|
|
63
70
|
# @return [void]
|
|
@@ -83,10 +90,20 @@ module Tuile
|
|
|
83
90
|
# @return [void]
|
|
84
91
|
def on_focus
|
|
85
92
|
super
|
|
86
|
-
#
|
|
87
|
-
# start
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
# Forward focus to the first interactive widget in the subtree so the
|
|
94
|
+
# user can start typing / cursoring immediately. Prefer a {#tab_stop?}
|
|
95
|
+
# descendant (TextField, List, Button…) so we skip past intermediate
|
|
96
|
+
# containers like a {Window} or another {Layout}. Fall back to the
|
|
97
|
+
# first focusable direct child for the rare case where the layout has
|
|
98
|
+
# focusable but non-tab-stop children (e.g. an empty {Window}).
|
|
99
|
+
first_tab_stop = nil
|
|
100
|
+
on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
|
|
101
|
+
if first_tab_stop
|
|
102
|
+
screen.focused = first_tab_stop
|
|
103
|
+
else
|
|
104
|
+
first_focusable = @children.find(&:focusable?)
|
|
105
|
+
screen.focused = first_focusable unless first_focusable.nil?
|
|
106
|
+
end
|
|
90
107
|
end
|
|
91
108
|
|
|
92
109
|
# Absolute layout. Extend this class, register any children, and
|