tuile 0.5.0 → 0.6.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 +15 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- 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/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- 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 +9 -10
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +96 -4
- data/lib/tuile/sizing.rb +59 -0
- 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/sig/tuile.rbs +627 -41
- metadata +5 -15
|
@@ -59,9 +59,8 @@ module Tuile
|
|
|
59
59
|
def repaint
|
|
60
60
|
return if rect.empty?
|
|
61
61
|
|
|
62
|
-
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
63
62
|
padded = @text + (" " * (rect.width - @text.length))
|
|
64
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top),
|
|
63
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
66
|
protected
|
|
@@ -88,21 +88,6 @@ module Tuile
|
|
|
88
88
|
invalidate
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
# 256-color SGR for the focused-button highlight (matches what
|
|
92
|
-
# `Rainbow(...).bg(:darkslategray)` emits, which is what
|
|
93
|
-
# {Component::Button#repaint} uses for its focused state).
|
|
94
|
-
# @return [String]
|
|
95
|
-
ACTIVE_BG_SGR = "\e[48;5;59m"
|
|
96
|
-
# 256-color SGR for the unfocused field's "well": index 238 sits in
|
|
97
|
-
# the grayscale ramp (~#444444), bright enough to stand out against
|
|
98
|
-
# non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
|
|
99
|
-
# backgrounds sit in the #1d–#2d range), and still distinctly darker
|
|
100
|
-
# than the active highlight at index 59 (~#5f5f5f). Rainbow's
|
|
101
|
-
# RGB-to-256 mapping snaps everything dark to palette index 16
|
|
102
|
-
# (terminal black), so we emit the escape directly to reach the ramp.
|
|
103
|
-
# @return [String]
|
|
104
|
-
INACTIVE_BG_SGR = "\e[48;5;238m"
|
|
105
|
-
|
|
106
91
|
# Handles a key. Returns false when the component is inactive. Otherwise
|
|
107
92
|
# first runs the {Component#handle_key} shortcut search via `super`, then
|
|
108
93
|
# delegates to {#handle_text_input_key}.
|
|
@@ -117,6 +102,16 @@ module Tuile
|
|
|
117
102
|
|
|
118
103
|
protected
|
|
119
104
|
|
|
105
|
+
# Renders `text` on the field's background well, looked up from the
|
|
106
|
+
# current {Screen#theme} at paint time: {Theme#active_bg} when this
|
|
107
|
+
# input is on the active (focus) chain, {Theme#input_bg} otherwise —
|
|
108
|
+
# visibly a field either way, distinctly highlighted when active.
|
|
109
|
+
# @param text [String]
|
|
110
|
+
# @return [String] ANSI-rendered text.
|
|
111
|
+
def background(text)
|
|
112
|
+
active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
|
|
113
|
+
end
|
|
114
|
+
|
|
120
115
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
121
116
|
# invalid input. Default coerces to String.
|
|
122
117
|
# @param new_text [String]
|
|
@@ -50,7 +50,6 @@ module Tuile
|
|
|
50
50
|
@physical_lines = []
|
|
51
51
|
@hard_line_wrap_counts = []
|
|
52
52
|
@text = StyledString::EMPTY
|
|
53
|
-
@content_size = Size::ZERO
|
|
54
53
|
@blank_line = StyledString::EMPTY
|
|
55
54
|
@top_line = 0
|
|
56
55
|
@auto_scroll = false
|
|
@@ -110,10 +109,10 @@ module Tuile
|
|
|
110
109
|
@regions = [Region.send(:new, self, @hard_lines.size)]
|
|
111
110
|
return if content_unchanged
|
|
112
111
|
|
|
113
|
-
@content_size = compute_content_size
|
|
114
112
|
rewrap
|
|
115
113
|
update_top_line_if_auto_scroll
|
|
116
114
|
invalidate
|
|
115
|
+
self.content_size = compute_content_size
|
|
117
116
|
end
|
|
118
117
|
|
|
119
118
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -180,9 +179,9 @@ module Tuile
|
|
|
180
179
|
|
|
181
180
|
tail_region.send(:line_count=, tail_region.line_count + added)
|
|
182
181
|
@text = nil
|
|
183
|
-
@content_size = compute_content_size
|
|
184
182
|
update_top_line_if_auto_scroll
|
|
185
183
|
invalidate
|
|
184
|
+
self.content_size = compute_content_size
|
|
186
185
|
end
|
|
187
186
|
|
|
188
187
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -254,10 +253,10 @@ module Tuile
|
|
|
254
253
|
end
|
|
255
254
|
|
|
256
255
|
@text = nil
|
|
257
|
-
@content_size = compute_content_size
|
|
258
256
|
@top_line = top_line_max if @top_line > top_line_max
|
|
259
257
|
update_top_line_if_auto_scroll
|
|
260
258
|
invalidate
|
|
259
|
+
self.content_size = compute_content_size
|
|
261
260
|
end
|
|
262
261
|
|
|
263
262
|
# Replaces a contiguous range of hard lines with the parsed content
|
|
@@ -315,10 +314,10 @@ module Tuile
|
|
|
315
314
|
splice_hard_lines(from, length, new_hard_lines)
|
|
316
315
|
update_region_counts(from, length, new_hard_lines.size)
|
|
317
316
|
@text = nil
|
|
318
|
-
@content_size = compute_content_size
|
|
319
317
|
@top_line = top_line_max if @top_line > top_line_max
|
|
320
318
|
update_top_line_if_auto_scroll
|
|
321
319
|
invalidate
|
|
320
|
+
self.content_size = compute_content_size
|
|
322
321
|
end
|
|
323
322
|
|
|
324
323
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -529,10 +528,10 @@ module Tuile
|
|
|
529
528
|
splice_hard_lines(start, old_count, new_lines)
|
|
530
529
|
region.send(:line_count=, new_lines.size)
|
|
531
530
|
@text = nil
|
|
532
|
-
@content_size = compute_content_size
|
|
533
531
|
@top_line = top_line_max if @top_line > top_line_max
|
|
534
532
|
update_top_line_if_auto_scroll
|
|
535
533
|
invalidate
|
|
534
|
+
self.content_size = compute_content_size
|
|
536
535
|
end
|
|
537
536
|
|
|
538
537
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -555,10 +554,10 @@ module Tuile
|
|
|
555
554
|
splice_hard_lines(abs_from, length, new_hard_lines)
|
|
556
555
|
region.send(:line_count=, region.line_count - length + new_hard_lines.size)
|
|
557
556
|
@text = nil
|
|
558
|
-
@content_size = compute_content_size
|
|
559
557
|
@top_line = top_line_max if @top_line > top_line_max
|
|
560
558
|
update_top_line_if_auto_scroll
|
|
561
559
|
invalidate
|
|
560
|
+
self.content_size = compute_content_size
|
|
562
561
|
end
|
|
563
562
|
|
|
564
563
|
# Verbatim append into `region`.
|
|
@@ -595,10 +594,10 @@ module Tuile
|
|
|
595
594
|
region.send(:line_count=, region.line_count + rest.size)
|
|
596
595
|
end
|
|
597
596
|
@text = nil
|
|
598
|
-
@content_size = compute_content_size
|
|
599
597
|
@top_line = top_line_max if @top_line > top_line_max
|
|
600
598
|
update_top_line_if_auto_scroll
|
|
601
599
|
invalidate
|
|
600
|
+
self.content_size = compute_content_size
|
|
602
601
|
end
|
|
603
602
|
|
|
604
603
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -617,10 +616,10 @@ module Tuile
|
|
|
617
616
|
splice_hard_lines(drop_from, to_drop, [])
|
|
618
617
|
region.send(:line_count=, region.line_count - to_drop)
|
|
619
618
|
@text = nil
|
|
620
|
-
@content_size = compute_content_size
|
|
621
619
|
@top_line = top_line_max if @top_line > top_line_max
|
|
622
620
|
update_top_line_if_auto_scroll
|
|
623
621
|
invalidate
|
|
622
|
+
self.content_size = compute_content_size
|
|
624
623
|
end
|
|
625
624
|
|
|
626
625
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -643,10 +642,10 @@ module Tuile
|
|
|
643
642
|
return unless had_lines
|
|
644
643
|
|
|
645
644
|
@text = nil
|
|
646
|
-
@content_size = compute_content_size
|
|
647
645
|
@top_line = top_line_max if @top_line > top_line_max
|
|
648
646
|
update_top_line_if_auto_scroll
|
|
649
647
|
invalidate
|
|
648
|
+
self.content_size = compute_content_size
|
|
650
649
|
end
|
|
651
650
|
|
|
652
651
|
# Adjusts region line counts after a {@hard_lines} splice that
|
|
@@ -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.
|
|
@@ -195,6 +197,38 @@ module Tuile
|
|
|
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
|
@@ -160,6 +160,16 @@ module Tuile
|
|
|
160
160
|
char += $stdin.read(6 - char.bytesize)
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
# Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
|
|
164
|
+
# can outgrow the 5-byte gulp above — the mode-2031 color-scheme
|
|
165
|
+
# notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
|
|
166
|
+
# bytes after the `\e`. Drain to the final byte with blocking 1-byte
|
|
167
|
+
# reads so the tail doesn't surface as phantom keypresses. Keyboard
|
|
168
|
+
# sequences never start with `\e[?`, so this can't eat a regular key.
|
|
169
|
+
if char.start_with?("\e[?")
|
|
170
|
+
char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
|
|
171
|
+
end
|
|
172
|
+
|
|
163
173
|
char
|
|
164
174
|
end
|
|
165
175
|
end
|
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
|
|
@@ -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)
|
|
@@ -476,6 +545,27 @@ module Tuile
|
|
|
476
545
|
|
|
477
546
|
private
|
|
478
547
|
|
|
548
|
+
# Startup color scheme: `:light` when {TerminalBackground.detect}
|
|
549
|
+
# reports a light terminal background, `:dark` otherwise (including
|
|
550
|
+
# when detection is inconclusive). Runs in the constructor — the
|
|
551
|
+
# OSC 11 reply arrives on stdin, which is only safe to read before
|
|
552
|
+
# {EventQueue#start_key_thread} owns it. {FakeScreen} overrides this
|
|
553
|
+
# to pin `:dark`, keeping specs deterministic and off the test
|
|
554
|
+
# runner's TTY.
|
|
555
|
+
# @return [Symbol] `:dark` or `:light`.
|
|
556
|
+
def detect_scheme
|
|
557
|
+
TerminalBackground.detect == :light ? :light : :dark
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
561
|
+
# scheme and apply the matching member of {#theme_def}.
|
|
562
|
+
# @param scheme [Symbol] `:dark` or `:light`.
|
|
563
|
+
# @return [void]
|
|
564
|
+
def on_color_scheme(scheme)
|
|
565
|
+
@scheme = scheme
|
|
566
|
+
self.theme = @theme_def.for(@scheme)
|
|
567
|
+
end
|
|
568
|
+
|
|
479
569
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
480
570
|
# advances focus by one (wrapping). When the focused component isn't in
|
|
481
571
|
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
@@ -596,6 +686,8 @@ module Tuile
|
|
|
596
686
|
when EventQueue::TTYSizeEvent
|
|
597
687
|
@size = event.size
|
|
598
688
|
layout
|
|
689
|
+
when EventQueue::ColorSchemeEvent
|
|
690
|
+
on_color_scheme(event.scheme)
|
|
599
691
|
when EventQueue::EmptyQueueEvent
|
|
600
692
|
repaint
|
|
601
693
|
when Proc
|