tuile 0.5.0 → 0.7.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 +20 -0
- data/README.md +151 -5
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +4 -3
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +122 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +13 -15
- data/lib/tuile/component/layout.rb +1 -1
- data/lib/tuile/component/list.rb +64 -31
- data/lib/tuile/component/log_window.rb +1 -0
- data/lib/tuile/component/picker_window.rb +4 -4
- data/lib/tuile/component/popup.rb +34 -3
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +33 -13
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +75 -6
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +9 -3
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +118 -20
- data/lib/tuile/screen_pane.rb +1 -1
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +125 -30
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +755 -62
- metadata +12 -18
|
@@ -19,9 +19,12 @@ module Tuile
|
|
|
19
19
|
super()
|
|
20
20
|
@border_right = 1
|
|
21
21
|
@caption = caption
|
|
22
|
+
@content = nil
|
|
22
23
|
# Optional bottom-row chrome that overlays the bottom border (e.g. a
|
|
23
24
|
# search field).
|
|
24
25
|
@footer = nil
|
|
26
|
+
@footer_sizing = Sizing::FILL
|
|
27
|
+
update_content_size
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def focusable? = true
|
|
@@ -30,8 +33,25 @@ module Tuile
|
|
|
30
33
|
# row.
|
|
31
34
|
attr_reader :footer
|
|
32
35
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
36
|
+
# @return [Sizing] how the footer's width is computed from the window's
|
|
37
|
+
# inner width; defaults to {Sizing::FILL} (the footer spans the full
|
|
38
|
+
# inner width). The footer's height is always 1 (the border row).
|
|
39
|
+
attr_reader :footer_sizing
|
|
40
|
+
|
|
41
|
+
# Sets the footer width policy and re-lays-out the footer.
|
|
42
|
+
# @param sizing [Sizing]
|
|
43
|
+
def footer_sizing=(sizing)
|
|
44
|
+
raise TypeError, "expected Sizing, got #{sizing.inspect}" unless sizing.is_a?(Sizing)
|
|
45
|
+
return if @footer_sizing == sizing
|
|
46
|
+
|
|
47
|
+
@footer_sizing = sizing
|
|
48
|
+
layout_footer
|
|
49
|
+
invalidate # repaint border cells the footer may have just vacated
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Sets the bottom-row chrome slot. The footer overlays the bottom border
|
|
53
|
+
# row and is positioned automatically — its width is governed by
|
|
54
|
+
# {#footer_sizing}; pass `nil` to remove.
|
|
35
55
|
#
|
|
36
56
|
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
37
57
|
# pointers, invalidates the old/new components and the window border, and
|
|
@@ -110,20 +130,36 @@ module Tuile
|
|
|
110
130
|
def caption=(new_caption)
|
|
111
131
|
@caption = new_caption
|
|
112
132
|
invalidate
|
|
133
|
+
update_content_size
|
|
113
134
|
end
|
|
114
135
|
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
# Sets the new content. Also recomputes the window's natural size.
|
|
137
|
+
# @param new_content [Component, nil]
|
|
138
|
+
def content=(new_content)
|
|
139
|
+
super
|
|
140
|
+
update_content_size
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
144
|
+
# size changes, and folds a content resize into the window's own
|
|
145
|
+
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
146
|
+
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
147
|
+
# participate in the window's {#content_size}: it is decoration
|
|
148
|
+
# overlaying the border, and must not drive the window's size — if it
|
|
149
|
+
# doesn't fit, it is clipped to the inner width.
|
|
150
|
+
# @param child [Component]
|
|
151
|
+
# @return [void]
|
|
152
|
+
def on_child_content_size_changed(child)
|
|
153
|
+
if child.equal?(@footer)
|
|
154
|
+
old_rect = @footer.rect
|
|
155
|
+
layout_footer
|
|
156
|
+
# Repaint on any footer geometry change: a shrinking footer vacates
|
|
157
|
+
# border cells that must be re-dashed (a growing one merely
|
|
158
|
+
# overdraws, but distinguishing isn't worth the code).
|
|
159
|
+
invalidate if @footer.rect != old_rect
|
|
160
|
+
else
|
|
161
|
+
update_content_size
|
|
162
|
+
end
|
|
127
163
|
end
|
|
128
164
|
|
|
129
165
|
# Fully repaints the window: both frame and contents.
|
|
@@ -150,6 +186,7 @@ module Tuile
|
|
|
150
186
|
super
|
|
151
187
|
# The shortcut key is shown in the caption — repaint.
|
|
152
188
|
invalidate
|
|
189
|
+
update_content_size
|
|
153
190
|
end
|
|
154
191
|
|
|
155
192
|
protected
|
|
@@ -166,7 +203,7 @@ module Tuile
|
|
|
166
203
|
return if rect.empty?
|
|
167
204
|
|
|
168
205
|
frame = build_frame(frame_caption)
|
|
169
|
-
frame =
|
|
206
|
+
frame = screen.theme.active_border(frame) if active?
|
|
170
207
|
screen.print frame
|
|
171
208
|
end
|
|
172
209
|
|
|
@@ -206,11 +243,28 @@ module Tuile
|
|
|
206
243
|
|
|
207
244
|
private
|
|
208
245
|
|
|
246
|
+
# Recomputes the window's natural size: content's natural size (or the
|
|
247
|
+
# caption, whichever is wider) plus the 2-character border. The footer
|
|
248
|
+
# is deliberately excluded — see {#on_child_content_size_changed}. A
|
|
249
|
+
# window with no content or caption sizes to `Size.new(2, 2)` (bare
|
|
250
|
+
# border).
|
|
251
|
+
# @return [void]
|
|
252
|
+
def update_content_size
|
|
253
|
+
inner_w = [content&.content_size&.width || 0, frame_caption.length].max
|
|
254
|
+
inner_h = content&.content_size&.height || 0
|
|
255
|
+
self.content_size = Size.new(inner_w + 2, inner_h + 2)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Positions the footer over the bottom border row, with its width
|
|
259
|
+
# resolved by {#footer_sizing} against the inner width. A
|
|
260
|
+
# {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
|
|
261
|
+
# rect — i.e. it is invisible, as if never assigned.
|
|
209
262
|
# @return [void]
|
|
210
263
|
def layout_footer
|
|
211
264
|
return if @footer.nil? || rect.empty?
|
|
212
265
|
|
|
213
|
-
|
|
266
|
+
available = [rect.width - 2, 0].max
|
|
267
|
+
width = @footer_sizing.resolve(available, @footer.content_size.width)
|
|
214
268
|
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
215
269
|
end
|
|
216
270
|
end
|
data/lib/tuile/component.rb
CHANGED
|
@@ -12,6 +12,8 @@ module Tuile
|
|
|
12
12
|
def initialize
|
|
13
13
|
@rect = Rect.new(0, 0, 0, 0)
|
|
14
14
|
@active = false
|
|
15
|
+
@content_size = Size::ZERO
|
|
16
|
+
@on_theme_changed = nil
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# @return [Rect] the rectangle the component occupies on screen.
|
|
@@ -188,13 +190,45 @@ module Tuile
|
|
|
188
190
|
# @return [void]
|
|
189
191
|
def on_tree(&block)
|
|
190
192
|
block.call(self)
|
|
191
|
-
children.each {
|
|
193
|
+
children.each { _1.on_tree(&block) }
|
|
192
194
|
end
|
|
193
195
|
|
|
194
196
|
# Called when the component receives focus.
|
|
195
197
|
# @return [void]
|
|
196
198
|
def on_focus; end
|
|
197
199
|
|
|
200
|
+
# Optional zero-arg listener fired by the base {#on_theme_changed} — the
|
|
201
|
+
# composition-style alternative to overriding the method, for apps that
|
|
202
|
+
# assemble stock components rather than subclass:
|
|
203
|
+
#
|
|
204
|
+
# label.on_theme_changed = -> { label.text = render_status_line }
|
|
205
|
+
#
|
|
206
|
+
# @return [Proc, nil]
|
|
207
|
+
attr_writer :on_theme_changed
|
|
208
|
+
|
|
209
|
+
# Called on every attached component (pre-order, popups included) when
|
|
210
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
211
|
+
# assignment and on OS appearance flips.
|
|
212
|
+
#
|
|
213
|
+
# Built-in components read {Screen#theme} at paint time, so their accents
|
|
214
|
+
# restyle automatically; this hook exists for *content* whose colors the
|
|
215
|
+
# app baked in from the old theme — a {Label#text} / {List#lines} /
|
|
216
|
+
# {TextView#text} {StyledString} styled with `theme[:accent]` and the
|
|
217
|
+
# like. Only the app knows which of its colors were theme-derived (as
|
|
218
|
+
# opposed to inherent to the data, e.g. log-level colors), so it rebuilds
|
|
219
|
+
# them here, re-running the same code that rendered them initially.
|
|
220
|
+
#
|
|
221
|
+
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
222
|
+
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
223
|
+
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
224
|
+
#
|
|
225
|
+
# Subclasses overriding this should call `super` so an assigned
|
|
226
|
+
# {#on_theme_changed=} listener keeps firing.
|
|
227
|
+
# @return [void]
|
|
228
|
+
def on_theme_changed
|
|
229
|
+
@on_theme_changed&.call
|
|
230
|
+
end
|
|
231
|
+
|
|
198
232
|
# @return [Boolean] true if this component's tree is currently mounted on
|
|
199
233
|
# the {Screen}, i.e. its root is the {ScreenPane}.
|
|
200
234
|
def attached? = root == screen.pane
|
|
@@ -225,12 +259,27 @@ module Tuile
|
|
|
225
259
|
|
|
226
260
|
# The {Size} big enough to show the entire component contents without
|
|
227
261
|
# scrolling. Plain components have no intrinsic content and report
|
|
228
|
-
# {Size::ZERO};
|
|
229
|
-
# {
|
|
230
|
-
#
|
|
231
|
-
# whatever content was assigned,
|
|
262
|
+
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
263
|
+
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
264
|
+
# their mutators, so reads are O(1). Used by callers like
|
|
265
|
+
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
266
|
+
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
232
267
|
# @return [Size]
|
|
233
|
-
|
|
268
|
+
attr_reader :content_size
|
|
269
|
+
|
|
270
|
+
# Called by a child component whose {#content_size} just changed (fired
|
|
271
|
+
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
272
|
+
# container is not size-coupled to its children. Containers that derive
|
|
273
|
+
# their own natural size or child layout from a child's natural size
|
|
274
|
+
# override this (e.g. {Component::Window} re-lays-out a
|
|
275
|
+
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
276
|
+
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
277
|
+
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
278
|
+
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
279
|
+
# keeps changing, and stops where it doesn't.
|
|
280
|
+
# @param child [Component] the resized direct child.
|
|
281
|
+
# @return [void]
|
|
282
|
+
def on_child_content_size_changed(child); end
|
|
234
283
|
|
|
235
284
|
# Where the hardware terminal cursor should sit when this component is the
|
|
236
285
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
@@ -253,6 +302,26 @@ module Tuile
|
|
|
253
302
|
# @return [void]
|
|
254
303
|
def on_width_changed; end
|
|
255
304
|
|
|
305
|
+
# Memoizes the component's natural size and notifies {#parent} via
|
|
306
|
+
# {#on_child_content_size_changed} when the value actually changed.
|
|
307
|
+
# Subclasses call this from their content mutators (`text=`, `add_lines`,
|
|
308
|
+
# `caption=`, …) instead of caching ad-hoc.
|
|
309
|
+
#
|
|
310
|
+
# Call this as the *last* step of a mutator: the parent hook may
|
|
311
|
+
# reentrantly reposition this component (assign {#rect} — e.g. {Window}
|
|
312
|
+
# re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
|
|
313
|
+
# triggers {#on_width_changed} and {#repaint}-related recomputation, so
|
|
314
|
+
# all internal state must already be consistent.
|
|
315
|
+
# @param new_size [Size]
|
|
316
|
+
# @return [void]
|
|
317
|
+
def content_size=(new_size)
|
|
318
|
+
raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
|
|
319
|
+
return if @content_size == new_size
|
|
320
|
+
|
|
321
|
+
@content_size = new_size
|
|
322
|
+
parent&.on_child_content_size_changed(self)
|
|
323
|
+
end
|
|
324
|
+
|
|
256
325
|
# Invalidates the component: {Screen} records this component as
|
|
257
326
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
258
327
|
#
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -184,6 +184,31 @@ module Tuile
|
|
|
184
184
|
def size = Size.new(width, height)
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
+
# The terminal's color scheme changed — the user flipped the OS between
|
|
188
|
+
# light and dark appearance. Terminals supporting mode 2031 (kitty,
|
|
189
|
+
# foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
|
|
190
|
+
# (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
|
|
191
|
+
# the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
|
|
192
|
+
# it into this event and {Screen#event_loop} follows by assigning the
|
|
193
|
+
# matching {Theme}.
|
|
194
|
+
#
|
|
195
|
+
# @!attribute [r] scheme
|
|
196
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
197
|
+
class ColorSchemeEvent < Data.define(:scheme)
|
|
198
|
+
# The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
|
|
199
|
+
# light.
|
|
200
|
+
# @return [Regexp]
|
|
201
|
+
REPORT = /\A\e\[\?997;([12])n\z/
|
|
202
|
+
|
|
203
|
+
# @param key [String] key read via {Keys.getkey}.
|
|
204
|
+
# @return [ColorSchemeEvent, nil] nil when `key` is not a
|
|
205
|
+
# color-scheme report.
|
|
206
|
+
def self.parse(key)
|
|
207
|
+
match = REPORT.match(key)
|
|
208
|
+
match && new(match[1] == "2" ? :light : :dark)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
187
212
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
188
213
|
# event loop will block waiting for more messages. Perfect time for
|
|
189
214
|
# repainting windows.
|
|
@@ -278,8 +303,7 @@ module Tuile
|
|
|
278
303
|
@key_thread = Thread.new do
|
|
279
304
|
loop do
|
|
280
305
|
key = Keys.getkey
|
|
281
|
-
event = MouseEvent.parse(key)
|
|
282
|
-
event = KeyEvent.new(key) if event.nil?
|
|
306
|
+
event = MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
|
|
283
307
|
post event
|
|
284
308
|
end
|
|
285
309
|
rescue StandardError => e
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -54,5 +54,13 @@ module Tuile
|
|
|
54
54
|
def invalidated_clear
|
|
55
55
|
@invalidated.clear
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
61
|
+
# (which would write an OSC 11 query to the test runner's TTY and
|
|
62
|
+
# steal its input) and pin the deterministic default.
|
|
63
|
+
# @return [Symbol]
|
|
64
|
+
def detect_scheme = :dark
|
|
57
65
|
end
|
|
58
66
|
end
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -156,9 +156,15 @@ module Tuile
|
|
|
156
156
|
# sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
|
|
157
157
|
# with a blocking read so the parser downstream sees a complete event
|
|
158
158
|
# instead of leaking tail bytes as keypresses.
|
|
159
|
-
if char.start_with?("\e[M") && char.bytesize < 6
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
|
|
160
|
+
|
|
161
|
+
# Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
|
|
162
|
+
# can outgrow the 5-byte gulp above — the mode-2031 color-scheme
|
|
163
|
+
# notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
|
|
164
|
+
# bytes after the `\e`. Drain to the final byte with blocking 1-byte
|
|
165
|
+
# reads so the tail doesn't surface as phantom keypresses. Keyboard
|
|
166
|
+
# sequences never start with `\e[?`, so this can't eat a regular key.
|
|
167
|
+
char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
|
|
162
168
|
|
|
163
169
|
char
|
|
164
170
|
end
|
data/lib/tuile/rect.rb
CHANGED
|
@@ -49,6 +49,18 @@ module Tuile
|
|
|
49
49
|
point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# @param other [Rect] another rectangle.
|
|
53
|
+
# @return [Boolean] true if `other` lies entirely within this rectangle.
|
|
54
|
+
# Uses the same half-open edges as {#contains?} (right/bottom exclusive).
|
|
55
|
+
# An {#empty? empty} `other` covers no cells, so it is trivially contained.
|
|
56
|
+
def contains_rect?(other)
|
|
57
|
+
return true if other.empty?
|
|
58
|
+
|
|
59
|
+
other.left >= left && other.top >= top &&
|
|
60
|
+
other.left + other.width <= left + width &&
|
|
61
|
+
other.top + other.height <= top + height
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
# @return [Size]
|
|
53
65
|
def size = Size.new(width, height)
|
|
54
66
|
|
data/lib/tuile/screen.rb
CHANGED
|
@@ -35,6 +35,9 @@ module Tuile
|
|
|
35
35
|
@repainting = Set.new
|
|
36
36
|
# Until the event loop is run, we pretend we're in the UI thread.
|
|
37
37
|
@pretend_ui_lock = true
|
|
38
|
+
@scheme = detect_scheme
|
|
39
|
+
@theme_def = ThemeDef.default
|
|
40
|
+
@theme = @theme_def.for(@scheme)
|
|
38
41
|
# Structural root of the component tree: holds tiled content, popup
|
|
39
42
|
# stack and status bar.
|
|
40
43
|
@pane = ScreenPane.new
|
|
@@ -93,6 +96,66 @@ module Tuile
|
|
|
93
96
|
# @return [Size] current screen size.
|
|
94
97
|
attr_reader :size
|
|
95
98
|
|
|
99
|
+
# The color {Theme} built-in components read at paint time: the member
|
|
100
|
+
# of {#theme_def} matching the terminal background detected at
|
|
101
|
+
# construction (see {TerminalBackground.detect}; inconclusive means
|
|
102
|
+
# dark). While the event loop runs, terminals supporting mode 2031
|
|
103
|
+
# push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
|
|
104
|
+
# screen re-picks from {#theme_def}.
|
|
105
|
+
# @return [Theme]
|
|
106
|
+
attr_reader :theme
|
|
107
|
+
|
|
108
|
+
# The app's {ThemeDef} — the dark/light {Theme} pair the screen picks
|
|
109
|
+
# {#theme} from, at startup and on every OS appearance flip. Starts as
|
|
110
|
+
# {ThemeDef.default} ({ThemeDef::DEFAULT} unless reassigned — tests
|
|
111
|
+
# do, see {ThemeDef.default=}). Assigning a custom definition is the
|
|
112
|
+
# durable way to theme an app: unlike a bare {#theme=}, it survives
|
|
113
|
+
# the user toggling the OS appearance.
|
|
114
|
+
# @return [ThemeDef]
|
|
115
|
+
attr_reader :theme_def
|
|
116
|
+
|
|
117
|
+
# Replaces the theme definition and immediately applies the member
|
|
118
|
+
# matching the current color scheme (via {#theme=}, so the whole UI
|
|
119
|
+
# restyles — or nothing repaints if that member equals the current
|
|
120
|
+
# theme).
|
|
121
|
+
# @param theme_def [ThemeDef]
|
|
122
|
+
# @return [void]
|
|
123
|
+
def theme_def=(theme_def)
|
|
124
|
+
raise TypeError, "expected ThemeDef, got #{theme_def.inspect}" unless theme_def.is_a?(ThemeDef)
|
|
125
|
+
|
|
126
|
+
check_locked
|
|
127
|
+
@theme_def = theme_def
|
|
128
|
+
self.theme = @theme_def.for(@scheme)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Replaces the theme and restyles the whole UI: fires
|
|
132
|
+
# {Component#on_theme_changed} across the attached tree (so the app can
|
|
133
|
+
# rebuild styled content whose colors were derived from the old theme),
|
|
134
|
+
# refreshes the status bar and invalidates every attached component so
|
|
135
|
+
# the next repaint uses the new colors. No-op when `new_theme` equals
|
|
136
|
+
# the current theme.
|
|
137
|
+
#
|
|
138
|
+
# This is a transient override: the next OS appearance flip re-picks
|
|
139
|
+
# from {#theme_def} and replaces it. To theme an app durably, assign
|
|
140
|
+
# {#theme_def=} instead.
|
|
141
|
+
#
|
|
142
|
+
# Note status-bar hints supplied by the host as preformatted strings
|
|
143
|
+
# (see {#register_global_shortcut}) have their colors baked in and are
|
|
144
|
+
# not restyled by this.
|
|
145
|
+
# @param new_theme [Theme]
|
|
146
|
+
# @return [void]
|
|
147
|
+
def theme=(new_theme)
|
|
148
|
+
raise TypeError, "expected Theme, got #{new_theme.inspect}" unless new_theme.is_a?(Theme)
|
|
149
|
+
|
|
150
|
+
check_locked
|
|
151
|
+
return if @theme == new_theme
|
|
152
|
+
|
|
153
|
+
@theme = new_theme
|
|
154
|
+
@pane&.on_tree(&:on_theme_changed)
|
|
155
|
+
refresh_status_bar
|
|
156
|
+
needs_full_repaint
|
|
157
|
+
end
|
|
158
|
+
|
|
96
159
|
# @return [Array<Component>] currently active popup components (forwarded
|
|
97
160
|
# to {ScreenPane}). The array must not be modified!
|
|
98
161
|
def popups = @pane.popups
|
|
@@ -143,7 +206,7 @@ module Tuile
|
|
|
143
206
|
check_locked
|
|
144
207
|
if focused.nil?
|
|
145
208
|
@focused = nil
|
|
146
|
-
@pane.on_tree {
|
|
209
|
+
@pane.on_tree { _1.active = false }
|
|
147
210
|
else
|
|
148
211
|
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
149
212
|
|
|
@@ -154,7 +217,7 @@ module Tuile
|
|
|
154
217
|
active << cursor
|
|
155
218
|
cursor = cursor.parent
|
|
156
219
|
end
|
|
157
|
-
@pane.on_tree {
|
|
220
|
+
@pane.on_tree { _1.active = active.include?(_1) }
|
|
158
221
|
@focused.on_focus
|
|
159
222
|
end
|
|
160
223
|
refresh_status_bar
|
|
@@ -172,7 +235,7 @@ module Tuile
|
|
|
172
235
|
top_popup = @pane.popups.last
|
|
173
236
|
globals = global_shortcut_hints(popup_open: !top_popup.nil?)
|
|
174
237
|
@pane.status_bar.text = if top_popup.nil?
|
|
175
|
-
["q #{
|
|
238
|
+
["q #{@theme.hint("quit")}", *globals,
|
|
176
239
|
active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
|
|
177
240
|
else
|
|
178
241
|
[*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
|
|
@@ -224,10 +287,15 @@ module Tuile
|
|
|
224
287
|
@pretend_ui_lock = false
|
|
225
288
|
$stdin.echo = false
|
|
226
289
|
print MouseEvent.start_tracking if capture_mouse
|
|
290
|
+
# Follow OS light/dark flips live: terminals supporting mode 2031
|
|
291
|
+
# push color-scheme reports that the key thread turns into
|
|
292
|
+
# {EventQueue::ColorSchemeEvent}s.
|
|
293
|
+
print TerminalBackground::NOTIFY_ON
|
|
227
294
|
$stdin.raw do
|
|
228
295
|
event_loop
|
|
229
296
|
end
|
|
230
297
|
ensure
|
|
298
|
+
print TerminalBackground::NOTIFY_OFF
|
|
231
299
|
print MouseEvent.stop_tracking if capture_mouse
|
|
232
300
|
print TTY::Cursor.show
|
|
233
301
|
$stdin.echo = true
|
|
@@ -272,7 +340,7 @@ module Tuile
|
|
|
272
340
|
#
|
|
273
341
|
# screen.register_global_shortcut(Keys::CTRL_L,
|
|
274
342
|
# over_popups: true,
|
|
275
|
-
# hint: "^L #{
|
|
343
|
+
# hint: "^L #{screen.theme.hint("log")}") do
|
|
276
344
|
# log_popup.open
|
|
277
345
|
# end
|
|
278
346
|
#
|
|
@@ -283,8 +351,9 @@ module Tuile
|
|
|
283
351
|
# (default), the shortcut is suppressed while any popup is open and
|
|
284
352
|
# the popup gets the key instead.
|
|
285
353
|
# @param hint [String, nil] preformatted status-bar hint (e.g.
|
|
286
|
-
# `"^L #{
|
|
287
|
-
# is silent in the status bar.
|
|
354
|
+
# `"^L #{screen.theme.hint("log")}"`). When nil (default) the shortcut
|
|
355
|
+
# is silent in the status bar. The colors are baked into the string,
|
|
356
|
+
# so a later {#theme=} does not restyle it — re-register if needed.
|
|
288
357
|
# @yield invoked with no arguments when `key` is pressed.
|
|
289
358
|
# @return [void]
|
|
290
359
|
def register_global_shortcut(key, over_popups: false, hint: nil, &block)
|
|
@@ -301,9 +370,7 @@ module Tuile
|
|
|
301
370
|
raise ArgumentError,
|
|
302
371
|
"#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
|
|
303
372
|
end
|
|
304
|
-
unless hint.nil? || hint.is_a?(String)
|
|
305
|
-
raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
|
|
306
|
-
end
|
|
373
|
+
raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
|
|
307
374
|
|
|
308
375
|
@global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
|
|
309
376
|
refresh_status_bar
|
|
@@ -322,7 +389,7 @@ module Tuile
|
|
|
322
389
|
def active_window
|
|
323
390
|
check_locked
|
|
324
391
|
result = nil
|
|
325
|
-
@pane.content&.on_tree { result =
|
|
392
|
+
@pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
|
|
326
393
|
result
|
|
327
394
|
end
|
|
328
395
|
|
|
@@ -335,10 +402,25 @@ module Tuile
|
|
|
335
402
|
# @return [void]
|
|
336
403
|
def remove_popup(window)
|
|
337
404
|
check_locked
|
|
405
|
+
return unless @pane.has_popup?(window)
|
|
406
|
+
|
|
338
407
|
@pane.remove_popup(window)
|
|
339
408
|
needs_full_repaint
|
|
340
409
|
end
|
|
341
410
|
|
|
411
|
+
# Invalidates the entire attached tree, forcing every component to repaint
|
|
412
|
+
# on the next cycle. Needed whenever something overdraws the scene without
|
|
413
|
+
# clipping and then exposes what was underneath — a closing popup
|
|
414
|
+
# ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
|
|
415
|
+
# longer covers the cells it previously painted ({Component::Popup#rect=}).
|
|
416
|
+
# The popup-only fast path in {#repaint} can't clear those vacated cells on
|
|
417
|
+
# its own, so we accept the cost of a full repaint.
|
|
418
|
+
# @api private
|
|
419
|
+
# @return [void]
|
|
420
|
+
def needs_full_repaint
|
|
421
|
+
@pane&.on_tree { invalidate _1 }
|
|
422
|
+
end
|
|
423
|
+
|
|
342
424
|
# Internal — use {Component::Popup#open?} instead.
|
|
343
425
|
# @api private
|
|
344
426
|
# @param window [Component::Popup]
|
|
@@ -426,8 +508,8 @@ module Tuile
|
|
|
426
508
|
# grandchild (depth 3) sorts after a popup's content (depth 2) and
|
|
427
509
|
# overdraws it.
|
|
428
510
|
popup_tree = Set.new
|
|
429
|
-
popups.each { |p| p.on_tree { popup_tree <<
|
|
430
|
-
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(
|
|
511
|
+
popups.each { |p| p.on_tree { popup_tree << _1 } }
|
|
512
|
+
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(_1) }
|
|
431
513
|
|
|
432
514
|
# Within the tiled tree, paint parents before children.
|
|
433
515
|
tiled.sort_by!(&:depth)
|
|
@@ -476,6 +558,27 @@ module Tuile
|
|
|
476
558
|
|
|
477
559
|
private
|
|
478
560
|
|
|
561
|
+
# Startup color scheme: `:light` when {TerminalBackground.detect}
|
|
562
|
+
# reports a light terminal background, `:dark` otherwise (including
|
|
563
|
+
# when detection is inconclusive). Runs in the constructor — the
|
|
564
|
+
# OSC 11 reply arrives on stdin, which is only safe to read before
|
|
565
|
+
# {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
|
|
566
|
+
# to pin `:dark`, keeping specs deterministic and off the test
|
|
567
|
+
# runner's TTY.
|
|
568
|
+
# @return [Symbol] `:dark` or `:light`.
|
|
569
|
+
def detect_scheme
|
|
570
|
+
TerminalBackground.detect == :light ? :light : :dark
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
574
|
+
# scheme and apply the matching member of {#theme_def}.
|
|
575
|
+
# @param scheme [Symbol] `:dark` or `:light`.
|
|
576
|
+
# @return [void]
|
|
577
|
+
def on_color_scheme(scheme)
|
|
578
|
+
@scheme = scheme
|
|
579
|
+
self.theme = @theme_def.for(@scheme)
|
|
580
|
+
end
|
|
581
|
+
|
|
479
582
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
480
583
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
481
584
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -510,7 +613,7 @@ module Tuile
|
|
|
510
613
|
# @return [Array<Component>]
|
|
511
614
|
def collect_subtree(component)
|
|
512
615
|
result = []
|
|
513
|
-
component.on_tree { result <<
|
|
616
|
+
component.on_tree { result << _1 }
|
|
514
617
|
result
|
|
515
618
|
end
|
|
516
619
|
|
|
@@ -536,13 +639,6 @@ module Tuile
|
|
|
536
639
|
repaint
|
|
537
640
|
end
|
|
538
641
|
|
|
539
|
-
# Called after a popup is closed. Since a popup can cover any window,
|
|
540
|
-
# top-level component or other popups, we need to redraw everything.
|
|
541
|
-
# @return [void]
|
|
542
|
-
def needs_full_repaint
|
|
543
|
-
@pane&.on_tree { invalidate it }
|
|
544
|
-
end
|
|
545
|
-
|
|
546
642
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
547
643
|
# window.
|
|
548
644
|
#
|
|
@@ -596,6 +692,8 @@ module Tuile
|
|
|
596
692
|
when EventQueue::TTYSizeEvent
|
|
597
693
|
@size = event.size
|
|
598
694
|
layout
|
|
695
|
+
when EventQueue::ColorSchemeEvent
|
|
696
|
+
on_color_scheme(event.scheme)
|
|
599
697
|
when EventQueue::EmptyQueueEvent
|
|
600
698
|
repaint
|
|
601
699
|
when Proc
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -140,7 +140,7 @@ module Tuile
|
|
|
140
140
|
# @param event [MouseEvent]
|
|
141
141
|
# @return [void]
|
|
142
142
|
def handle_mouse(event)
|
|
143
|
-
clicked = @popups.reverse_each.find {
|
|
143
|
+
clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
|
|
144
144
|
clicked = @content if clicked.nil? && @popups.empty?
|
|
145
145
|
clicked&.handle_mouse(event)
|
|
146
146
|
end
|
data/lib/tuile/sizing.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A sizing policy for a slot whose position is managed by a parent
|
|
5
|
+
# component (e.g. {Component::Window#footer}). Resolves one dimension at a
|
|
6
|
+
# time via {#resolve}, so the same value works for widths and heights.
|
|
7
|
+
#
|
|
8
|
+
# Three policies exist:
|
|
9
|
+
#
|
|
10
|
+
# - {FILL} — take everything the slot offers;
|
|
11
|
+
# - {WRAP_CONTENT} — take the component's natural extent (its
|
|
12
|
+
# {Component#content_size}), clamped to the slot;
|
|
13
|
+
# - {.fixed} — take exactly the given number of cells, clamped to the slot.
|
|
14
|
+
#
|
|
15
|
+
# Note that {WRAP_CONTENT} only makes sense for components that report a
|
|
16
|
+
# natural {Component#content_size} ({Component::Label}, {Component::Button},
|
|
17
|
+
# {Component::List}, …). Input components ({Component::TextField} et al.)
|
|
18
|
+
# report {Size::ZERO}, so a wrap-content slot collapses to zero width —
|
|
19
|
+
# i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute [r] mode
|
|
22
|
+
# @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
|
|
23
|
+
# @!attribute [r] amount
|
|
24
|
+
# @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
|
|
25
|
+
class Sizing < Data.define(:mode, :amount)
|
|
26
|
+
# @param amount [Integer] the number of cells to occupy; 0 or greater.
|
|
27
|
+
# @return [Sizing] a fixed-size policy.
|
|
28
|
+
def self.fixed(amount)
|
|
29
|
+
raise TypeError, "expected Integer, got #{amount.inspect}" unless amount.is_a?(Integer)
|
|
30
|
+
raise ArgumentError, "amount must not be negative, got #{amount}" if amount.negative?
|
|
31
|
+
|
|
32
|
+
new(mode: :fixed, amount: amount)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resolves one dimension of a slot.
|
|
36
|
+
# @param available [Integer] cells the slot offers; 0 or greater.
|
|
37
|
+
# @param content [Integer] the component's natural extent on this axis
|
|
38
|
+
# (one dimension of its {Component#content_size}).
|
|
39
|
+
# @return [Integer] the resolved extent, always in `0..available`.
|
|
40
|
+
def resolve(available, content)
|
|
41
|
+
case mode
|
|
42
|
+
when :fill then available
|
|
43
|
+
when :fixed then amount.clamp(0, available)
|
|
44
|
+
when :wrap_content then content.clamp(0, available)
|
|
45
|
+
else raise ArgumentError, "unknown mode #{mode.inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Occupy everything the slot offers.
|
|
50
|
+
# @return [Sizing]
|
|
51
|
+
FILL = new(mode: :fill, amount: nil)
|
|
52
|
+
|
|
53
|
+
# Occupy the component's natural {Component#content_size}, clamped to the
|
|
54
|
+
# slot. Components reporting {Size::ZERO} collapse to invisibility — see
|
|
55
|
+
# the class doc.
|
|
56
|
+
# @return [Sizing]
|
|
57
|
+
WRAP_CONTENT = new(mode: :wrap_content, amount: nil)
|
|
58
|
+
end
|
|
59
|
+
end
|