tuile 0.13.0 → 0.14.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 +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- metadata +8 -1
data/lib/tuile/component.rb
CHANGED
|
@@ -9,7 +9,43 @@ module Tuile
|
|
|
9
9
|
# any stale invalidation entries are filtered out at drain time. Subclasses
|
|
10
10
|
# can paint freely in {#repaint} without re-asserting attachment.
|
|
11
11
|
class Component
|
|
12
|
+
# The methods that *define* the tree, and so may never be overridden —
|
|
13
|
+
# {#attached?} walks the parent chain while every subtree walk uses
|
|
14
|
+
# {#children}, and an override that makes those two disagree leaves a
|
|
15
|
+
# component attached but never painted, with nothing raising (`D_final_tree`).
|
|
16
|
+
# Enforced by {.verify_final!}; reparent through {#add_child} /
|
|
17
|
+
# {#remove_child} / {#detach_child}, and hold a {Slot} for a swappable region.
|
|
18
|
+
# @return [Array<Symbol>]
|
|
19
|
+
FINAL_METHODS = %i[children parent parent= add_child remove_child detach_child].freeze
|
|
20
|
+
|
|
21
|
+
@final_verified = {}
|
|
22
|
+
|
|
23
|
+
# Raises unless `klass` inherits every {FINAL_METHODS} entry from
|
|
24
|
+
# {Component}; memoized, so it costs one hash lookup per construction.
|
|
25
|
+
#
|
|
26
|
+
# Comparing each *resolved* method's `owner` is what makes this catch all
|
|
27
|
+
# four routes in — `def`, `define_method`, an included module, a `prepend`.
|
|
28
|
+
# A `method_added` hook would fire earlier but see only the first two.
|
|
29
|
+
# @param klass [Class] the class being instantiated.
|
|
30
|
+
# @raise [Error] if any final method has been overridden.
|
|
31
|
+
# @return [void]
|
|
32
|
+
def self.verify_final!(klass)
|
|
33
|
+
return if @final_verified.key?(klass)
|
|
34
|
+
|
|
35
|
+
overridden = FINAL_METHODS.reject { klass.instance_method(_1).owner == Component }
|
|
36
|
+
unless overridden.empty?
|
|
37
|
+
raise Error, "#{klass} overrides #{overridden.join(", ")}, which are final on Component. " \
|
|
38
|
+
"The tree is the framework's single source of truth — #attached? walks the " \
|
|
39
|
+
"parent chain while subtree walks use #children, so an override desyncs them " \
|
|
40
|
+
"silently. Reparent through add_child / remove_child / detach_child; for a " \
|
|
41
|
+
"swappable region, hold a Component::Slot."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@final_verified[klass] = true
|
|
45
|
+
end
|
|
46
|
+
|
|
12
47
|
def initialize
|
|
48
|
+
Component.verify_final!(self.class)
|
|
13
49
|
@rect = Rect.new(0, 0, 0, 0)
|
|
14
50
|
@active = false
|
|
15
51
|
@on_theme_changed = nil
|
|
@@ -20,6 +56,65 @@ module Tuile
|
|
|
20
56
|
# @return [Rect] the rectangle the component occupies on screen.
|
|
21
57
|
attr_reader :rect
|
|
22
58
|
|
|
59
|
+
# The three readers below report the geometry a parent *assigned*, as
|
|
60
|
+
# shorthand for the matching {#rect} field. They are reports, not requests:
|
|
61
|
+
# no container consults them when dividing space, and there is deliberately
|
|
62
|
+
# no writer — layout is top-down (`DECISIONS.md` `D_box_layouts`), so a
|
|
63
|
+
# component says how big it *is*, never how big it wants to be.
|
|
64
|
+
|
|
65
|
+
# @return [Size] `rect.size`.
|
|
66
|
+
def size = rect.size
|
|
67
|
+
|
|
68
|
+
# @return [Integer] `rect.width`.
|
|
69
|
+
def width = rect.width
|
|
70
|
+
|
|
71
|
+
# @return [Integer] `rect.height`.
|
|
72
|
+
def height = rect.height
|
|
73
|
+
|
|
74
|
+
# The size of the region this component paints, or `nil` (the default) to
|
|
75
|
+
# declare nothing — in which case the whole {#rect} is treated as fair game
|
|
76
|
+
# and the default {#repaint} blanks all of it. Override it when you paint
|
|
77
|
+
# less: a one-row {Component::Checkbox} handed a tall column, or a
|
|
78
|
+
# {Component::Select} used as a {Component::Popup}'s content and assigned the
|
|
79
|
+
# whole inner box.
|
|
80
|
+
#
|
|
81
|
+
# It always sits at {#rect}'s top-left — which is why this is a {Size} and
|
|
82
|
+
# not a {Rect}: an offset extent is not merely unsupported, it is
|
|
83
|
+
# unrepresentable. Use {#extent_rect} where coordinates are wanted.
|
|
84
|
+
#
|
|
85
|
+
# **`nil` is not the same as `rect.size`.** `nil` says "I have not declared
|
|
86
|
+
# what I paint, so clear everything before I do", which is what a
|
|
87
|
+
# {Component::Label} with short text needs. A declared extent — even one that
|
|
88
|
+
# happens to equal the rect, as a one-row {Component::Select} in a one-row
|
|
89
|
+
# rect does — says "I paint this in full, don't blank it", which is what
|
|
90
|
+
# keeps the default {#repaint} from dirtying cells it is about to redraw
|
|
91
|
+
# (`D_progress_bar`). The base cannot tell those apart from the value alone;
|
|
92
|
+
# that is what the `nil` carries.
|
|
93
|
+
#
|
|
94
|
+
# It flows **downward only**: no container consults it when dividing space,
|
|
95
|
+
# so {#rect} still means exactly what the parent assigned (`D_extent`). Three
|
|
96
|
+
# things read it, all of them this component or the framework painting it:
|
|
97
|
+
# {#clear_outside_extent} blanks the dead tail, {#handle_mouse} hit-tests
|
|
98
|
+
# against it so a click on that tail doesn't activate the widget, and a
|
|
99
|
+
# dropdown anchors under it rather than under unused space.
|
|
100
|
+
#
|
|
101
|
+
# **An override promises to paint the extent in full**, so `super` in
|
|
102
|
+
# {#repaint} blanks only what is outside it. The arithmetic is each widget's
|
|
103
|
+
# own — caption width, painted strip, one row — and must not vary with
|
|
104
|
+
# {#bg_color} (`D_boolean_fields`).
|
|
105
|
+
# @return [Size, nil]
|
|
106
|
+
def extent = nil
|
|
107
|
+
|
|
108
|
+
# {#extent} placed at {#rect}'s top-left, for the consumers that need
|
|
109
|
+
# coordinates: `extent_rect.contains?(event.point)` in a {#handle_mouse}, and
|
|
110
|
+
# the anchor a dropdown hangs from. Total — an undeclared {#extent} yields
|
|
111
|
+
# the whole {#rect}, so a generic caller never sees `nil`.
|
|
112
|
+
# @return [Rect]
|
|
113
|
+
def extent_rect
|
|
114
|
+
e = extent
|
|
115
|
+
e.nil? ? rect : Rect.new(rect.left, rect.top, e.width, e.height)
|
|
116
|
+
end
|
|
117
|
+
|
|
23
118
|
# Sets new position of the component. This is the absolute component
|
|
24
119
|
# positioning on screen, not a relative positioning relative to component's
|
|
25
120
|
# {#parent}.
|
|
@@ -104,6 +199,11 @@ module Tuile
|
|
|
104
199
|
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
105
200
|
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
106
201
|
#
|
|
202
|
+
# **A widget that paints less than its rect declares an {#extent} rather than
|
|
203
|
+
# skipping `super`.** The clear then covers only what is outside it, so the
|
|
204
|
+
# cells it is about to repaint are not blanked first — blanking them would
|
|
205
|
+
# mark them dirty and make {Buffer#flush} re-emit them (`D_progress_bar`).
|
|
206
|
+
#
|
|
107
207
|
# **The children are re-invalidated whether or not they tile.** A container
|
|
108
208
|
# that paints nothing of its own can only redraw its area *through* them, so
|
|
109
209
|
# a tiling container that skipped this would be a dead end in the cascade: an
|
|
@@ -116,7 +216,7 @@ module Tuile
|
|
|
116
216
|
def repaint
|
|
117
217
|
return if rect.empty?
|
|
118
218
|
|
|
119
|
-
|
|
219
|
+
clear_outside_extent unless children.any? && children_tile_rect?
|
|
120
220
|
children.each { |c| screen.invalidate(c) }
|
|
121
221
|
end
|
|
122
222
|
|
|
@@ -152,12 +252,21 @@ module Tuile
|
|
|
152
252
|
false
|
|
153
253
|
end
|
|
154
254
|
|
|
155
|
-
#
|
|
156
|
-
#
|
|
255
|
+
# Focuses this component when left-clicked (if {#focusable?}), then hands the
|
|
256
|
+
# event down to every child whose {#rect} contains the point — which is how a
|
|
257
|
+
# click descends the tiled tree to a leaf.
|
|
258
|
+
#
|
|
259
|
+
# A widget that resolves clicks *inside* its own rect — mapping a point to a
|
|
260
|
+
# row, or toggling an overlay — overrides this and does not call `super`.
|
|
261
|
+
# Such an override hit-tests {#extent_rect} rather than {#rect}, so a click
|
|
262
|
+
# on the tail it doesn't paint never activates it.
|
|
157
263
|
# @param event [MouseEvent]
|
|
158
264
|
# @return [void]
|
|
159
265
|
def handle_mouse(event)
|
|
160
266
|
screen.focused = self unless event.button != :left || active? || !focusable?
|
|
267
|
+
# Snapshot: a handler may add or remove siblings (a click that swaps a
|
|
268
|
+
# slot's occupant), and `each` over a mutating array skips an entry.
|
|
269
|
+
children.dup.each { |c| c.handle_mouse(event) if c.rect.contains?(event.point) }
|
|
161
270
|
end
|
|
162
271
|
|
|
163
272
|
# @return [Boolean] true if the component is on the active chain — i.e. it
|
|
@@ -238,22 +347,6 @@ module Tuile
|
|
|
238
347
|
# @return [Proc, nil]
|
|
239
348
|
attr_writer :on_theme_changed
|
|
240
349
|
|
|
241
|
-
# Called on every attached component (pre-order, popups included) when
|
|
242
|
-
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
243
|
-
# OS appearance flips. The hook exists for app *content* whose colors were
|
|
244
|
-
# baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
|
|
245
|
-
# styled with `theme[:accent]`); rebuild it here by re-running the code that
|
|
246
|
-
# rendered it. See book ch6 for why built-in accents need no such handling.
|
|
247
|
-
#
|
|
248
|
-
# Runs on the UI thread with {Screen#theme} already updated, so mutating
|
|
249
|
-
# content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
|
|
250
|
-
# here. Subclasses overriding this must call `super` so an assigned
|
|
251
|
-
# {#on_theme_changed=} listener keeps firing.
|
|
252
|
-
# @return [void]
|
|
253
|
-
def on_theme_changed
|
|
254
|
-
@on_theme_changed&.call
|
|
255
|
-
end
|
|
256
|
-
|
|
257
350
|
# Whether this component's tree is mounted on a UI, {ScreenPane} being the
|
|
258
351
|
# root of every displayed tree.
|
|
259
352
|
#
|
|
@@ -427,6 +520,26 @@ module Tuile
|
|
|
427
520
|
# @return [void]
|
|
428
521
|
def on_width_changed; end
|
|
429
522
|
|
|
523
|
+
# Called on every attached component (pre-order, popups included) when
|
|
524
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
525
|
+
# OS appearance flips. The hook exists for app *content* whose colors were
|
|
526
|
+
# baked in from the old theme (a {Label#text} / {List#lines=} {StyledString}
|
|
527
|
+
# styled with `theme[:accent]`); rebuild it here by re-running the code that
|
|
528
|
+
# rendered it. See book ch6 for why built-in accents need no such handling.
|
|
529
|
+
#
|
|
530
|
+
# Runs on the UI thread with {Screen#theme} already updated, so mutating
|
|
531
|
+
# content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
|
|
532
|
+
# here. Subclasses overriding this must call `super` so an assigned
|
|
533
|
+
# {#on_theme_changed=} listener keeps firing.
|
|
534
|
+
#
|
|
535
|
+
# Plumbing an app overrides and never calls, hence protected — and
|
|
536
|
+
# {Screen}, not being a {Component}, fans it out through `__send__`, so an
|
|
537
|
+
# override is free to declare any visibility (`D_hook_visibility`).
|
|
538
|
+
# @return [void]
|
|
539
|
+
def on_theme_changed
|
|
540
|
+
@on_theme_changed&.call
|
|
541
|
+
end
|
|
542
|
+
|
|
430
543
|
# Invalidates the component: {Screen} records this component as
|
|
431
544
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
432
545
|
#
|
|
@@ -455,6 +568,23 @@ module Tuile
|
|
|
455
568
|
total >= rect.width * rect.height
|
|
456
569
|
end
|
|
457
570
|
|
|
571
|
+
# Blanks the part of {#rect} outside {#extent} — the dead tail a widget that
|
|
572
|
+
# paints less than it was given must not leave stale. Up to two regions,
|
|
573
|
+
# since a narrowed extent leaves an L: the columns right of it, and the rows
|
|
574
|
+
# below it. A `nil` extent declares nothing, so the whole rect is blanked.
|
|
575
|
+
# Called by the default {#repaint}; a self-painter that skips `super` calls
|
|
576
|
+
# it directly.
|
|
577
|
+
# @return [void]
|
|
578
|
+
def clear_outside_extent
|
|
579
|
+
e = extent
|
|
580
|
+
return clear_background if e.nil? # nothing declared: all of it is fair game
|
|
581
|
+
|
|
582
|
+
right = Rect.new(rect.left + e.width, rect.top, rect.width - e.width, e.height)
|
|
583
|
+
below = Rect.new(rect.left, rect.top + e.height, rect.width, rect.height - e.height)
|
|
584
|
+
clear_background(right) unless right.empty?
|
|
585
|
+
clear_background(below) unless below.empty?
|
|
586
|
+
end
|
|
587
|
+
|
|
458
588
|
# Clears the background: fills every cell with a blank in the
|
|
459
589
|
# {#effective_bg_color} (the terminal default when none is inherited).
|
|
460
590
|
#
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -247,6 +247,25 @@ module Tuile
|
|
|
247
247
|
end
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# The terminal reported its background color — an OSC 11 reply, read
|
|
251
|
+
# back as a whole "key" by {Keys.getkey}.
|
|
252
|
+
#
|
|
253
|
+
# Only ever an answer to a {TerminalBackground::QUERY} someone wrote,
|
|
254
|
+
# and only the color: a {ColorSchemeEvent} is what prompts the re-probe
|
|
255
|
+
# and what carries the light/dark half. See {Screen#background_color}.
|
|
256
|
+
#
|
|
257
|
+
# @!attribute [r] color
|
|
258
|
+
# @return [Color] the reported background, 24-bit RGB.
|
|
259
|
+
class BackgroundColorEvent < Data.define(:color)
|
|
260
|
+
# @param key [String] key read via {Keys.getkey}.
|
|
261
|
+
# @return [BackgroundColorEvent, nil] nil when `key` is not an OSC 11
|
|
262
|
+
# background reply.
|
|
263
|
+
def self.parse(key)
|
|
264
|
+
result = TerminalBackground.parse(key)
|
|
265
|
+
result&.color && new(result.color)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
250
269
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
251
270
|
# event loop will block waiting for more messages. Perfect time for
|
|
252
271
|
# repainting windows.
|
|
@@ -344,7 +363,8 @@ module Tuile
|
|
|
344
363
|
event = if key == Keys::PASTE_START
|
|
345
364
|
PasteEvent.new(Keys.read_paste)
|
|
346
365
|
else
|
|
347
|
-
MouseEvent.parse(key) || ColorSchemeEvent.parse(key) ||
|
|
366
|
+
MouseEvent.parse(key) || ColorSchemeEvent.parse(key) ||
|
|
367
|
+
BackgroundColorEvent.parse(key) || KeyEvent.new(key)
|
|
348
368
|
end
|
|
349
369
|
post event
|
|
350
370
|
end
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -81,12 +81,36 @@ module Tuile
|
|
|
81
81
|
@invalidated.clear
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Plays the terminal answering the OSC 11 re-probe, so a spec can
|
|
85
|
+
# exercise app code that derives colors from {#background_color}:
|
|
86
|
+
#
|
|
87
|
+
# Screen.instance.background_color = Color.rgb(30, 30, 46)
|
|
88
|
+
#
|
|
89
|
+
# Takes the same path a real reply does — a changed color fires
|
|
90
|
+
# {Component#on_theme_changed} across the tree and invalidates it.
|
|
91
|
+
# There is no such writer on {Screen}: the value is a report from the
|
|
92
|
+
# terminal, not a setting.
|
|
93
|
+
# @param color [Color]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def background_color=(color)
|
|
96
|
+
on_background_color(color)
|
|
97
|
+
end
|
|
98
|
+
|
|
84
99
|
private
|
|
85
100
|
|
|
86
101
|
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
87
102
|
# (which would write an OSC 11 query to the test runner's TTY and
|
|
88
|
-
# steal its input) and pin the deterministic default.
|
|
103
|
+
# steal its input) and pin the deterministic default. The color is nil
|
|
104
|
+
# — the case every app must handle anyway — until a spec assigns one
|
|
105
|
+
# through {#background_color=}.
|
|
106
|
+
# @return [TerminalBackground::Result]
|
|
107
|
+
def detect_background = TerminalBackground::Result.new(scheme: :dark, color: nil)
|
|
108
|
+
|
|
109
|
+
# Pins the depth rather than reading the test runner's environment, so a
|
|
110
|
+
# spec asserting flushed bytes gets the same answer on a truecolor
|
|
111
|
+
# terminal, under `TERM=dumb` in CI, and inside tmux. A spec exercising
|
|
112
|
+
# degradation builds its own {Buffer} with the depth it wants.
|
|
89
113
|
# @return [Symbol]
|
|
90
|
-
def
|
|
114
|
+
def detect_color_depth = :truecolor
|
|
91
115
|
end
|
|
92
116
|
end
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -184,6 +184,13 @@ module Tuile
|
|
|
184
184
|
# sequences never start with `\e[?`, so this can't eat a regular key.
|
|
185
185
|
char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
|
|
186
186
|
|
|
187
|
+
# OSC replies (the ~22-byte background report, {TerminalBackground})
|
|
188
|
+
# outgrow the gulp too, and end in BEL or ST rather than at a fixed
|
|
189
|
+
# length. Byte-at-a-time is required, not merely tidy: ST *is* `\e\\`,
|
|
190
|
+
# so a gulping read would swallow it plus the keys typed behind it.
|
|
191
|
+
# Keyboard sequences never start with `\e]`, so this eats no real key.
|
|
192
|
+
char += $stdin.read(1) while char.start_with?("\e]") && !char.end_with?("\a", "\e\\")
|
|
193
|
+
|
|
187
194
|
char
|
|
188
195
|
end
|
|
189
196
|
|
data/lib/tuile/screen.rb
CHANGED
|
@@ -16,13 +16,13 @@ module Tuile
|
|
|
16
16
|
# out its own children), the modal/overlay {#popups} stack (opened via
|
|
17
17
|
# {Component::Popup#open}, drawn on top of the content). Popups are *not*
|
|
18
18
|
# sized from their content — each carries its own top-down
|
|
19
|
-
# {Component::Popup#
|
|
19
|
+
# {Component::Popup#declared_size} — and they deliberately overdraw the content
|
|
20
20
|
# without clipping.
|
|
21
21
|
#
|
|
22
22
|
# Tuile draws no chrome of its own: there is no status bar and no reserved
|
|
23
23
|
# row, so {#content} gets the whole terminal. An app that wants a status line
|
|
24
24
|
# builds one into its own layout and drives it from {#on_focus_changed=}
|
|
25
|
-
# (`
|
|
25
|
+
# (`D_status_bar`).
|
|
26
26
|
#
|
|
27
27
|
# ## Repaint model
|
|
28
28
|
#
|
|
@@ -71,7 +71,10 @@ module Tuile
|
|
|
71
71
|
# during :idle, at both ends of the screen's life. See {#check_locked}.
|
|
72
72
|
@ui_thread = Thread.current
|
|
73
73
|
@closed = false
|
|
74
|
-
|
|
74
|
+
background = detect_background
|
|
75
|
+
@color_scheme = background.scheme
|
|
76
|
+
@background_color = background.color
|
|
77
|
+
@color_depth = detect_color_depth
|
|
75
78
|
@theme_def = ThemeDef.default
|
|
76
79
|
@theme = @theme_def.for(@color_scheme)
|
|
77
80
|
# Structural root of the component tree: holds tiled content and the
|
|
@@ -84,7 +87,7 @@ module Tuile
|
|
|
84
87
|
# The back buffer components paint into. {#repaint} flushes its diff to
|
|
85
88
|
# the terminal, so only changed cells are emitted (flicker-free on any
|
|
86
89
|
# terminal). Sized to the current viewport; {#layout} resizes it.
|
|
87
|
-
@buffer = Buffer.new(@size)
|
|
90
|
+
@buffer = Buffer.new(@size, color_depth: @color_depth)
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
# Entry in the global shortcut registry: the block to run, and whether it
|
|
@@ -118,6 +121,17 @@ module Tuile
|
|
|
118
121
|
# @return [Symbol] `:light` or `:dark`
|
|
119
122
|
attr_reader :color_scheme
|
|
120
123
|
|
|
124
|
+
# How many colors this terminal can show ({ColorDepth::DEPTHS}), detected
|
|
125
|
+
# at construction. {Buffer#flush} degrades every color it emits to this,
|
|
126
|
+
# so an app may compute an RGB tint — say from {#background_color} — and
|
|
127
|
+
# paint with it, whatever the terminal turns out to understand.
|
|
128
|
+
#
|
|
129
|
+
# Deliberately read-only: detection runs once and the answer can't change
|
|
130
|
+
# mid-session. Override a terminal that reports itself wrong through
|
|
131
|
+
# {ColorDepth::OVERRIDE_ENV} instead.
|
|
132
|
+
# @return [Symbol]
|
|
133
|
+
attr_reader :color_depth
|
|
134
|
+
|
|
121
135
|
# @return [Buffer] the back buffer components paint into
|
|
122
136
|
# ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
|
|
123
137
|
attr_reader :buffer
|
|
@@ -183,6 +197,26 @@ module Tuile
|
|
|
183
197
|
# @return [ThemeDef]
|
|
184
198
|
attr_reader :theme_def
|
|
185
199
|
|
|
200
|
+
# The terminal's own background, as it reported it — for deriving a
|
|
201
|
+
# color *from* the background rather than picking one against it. A pane
|
|
202
|
+
# tinted a few percent off it sits right on any terminal, where a fixed
|
|
203
|
+
# near-neutral only sits right near the one it was tuned on:
|
|
204
|
+
#
|
|
205
|
+
# bg = screen.background_color
|
|
206
|
+
# sidebar.bg_color =
|
|
207
|
+
# bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
|
|
208
|
+
#
|
|
209
|
+
# **Nil is the normal case, not an edge** — a terminal answering only
|
|
210
|
+
# `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
|
|
211
|
+
#
|
|
212
|
+
# Kept current across OS appearance flips, a frame behind: the flip
|
|
213
|
+
# report carries light/dark only, so the screen re-probes and this
|
|
214
|
+
# updates when the reply lands. A changed color then fires
|
|
215
|
+
# {Component#on_theme_changed} across the tree exactly as a theme swap
|
|
216
|
+
# does — a background-derived tint *is* a theme-derived color.
|
|
217
|
+
# @return [Color, nil]
|
|
218
|
+
attr_reader :background_color
|
|
219
|
+
|
|
186
220
|
# Replaces the theme definition and immediately applies the member
|
|
187
221
|
# matching the current color scheme (via {#theme=}, so the whole UI
|
|
188
222
|
# restyles — or nothing repaints if that member equals the current
|
|
@@ -211,12 +245,15 @@ module Tuile
|
|
|
211
245
|
return if @theme == new_theme
|
|
212
246
|
|
|
213
247
|
@theme = new_theme
|
|
214
|
-
|
|
248
|
+
# `__send__`, not `&:on_theme_changed`: the hook is protected, and an app
|
|
249
|
+
# subclass may narrow it further (`D_hook_visibility`).
|
|
250
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
215
251
|
needs_full_repaint
|
|
216
252
|
end
|
|
217
253
|
|
|
218
|
-
# @return [Array<Component>]
|
|
219
|
-
#
|
|
254
|
+
# @return [Array<Component::Overlay>] the open overlay stack — both
|
|
255
|
+
# {Component::Popup} modals and bare {Component::Overlay}s — in stacking
|
|
256
|
+
# order (forwarded to {ScreenPane}). The array must not be modified!
|
|
220
257
|
def popups = @pane.popups
|
|
221
258
|
|
|
222
259
|
# @return [EventQueue] the event queue.
|
|
@@ -316,7 +353,7 @@ module Tuile
|
|
|
316
353
|
#
|
|
317
354
|
# This is the hook an app drives its own status line from. Tuile owns no
|
|
318
355
|
# status bar and reserves no row: build a {Component::Label} into your own
|
|
319
|
-
# layout and fill it here (`
|
|
356
|
+
# layout and fill it here (`D_status_bar`).
|
|
320
357
|
#
|
|
321
358
|
# screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
|
|
322
359
|
#
|
|
@@ -335,10 +372,10 @@ module Tuile
|
|
|
335
372
|
# @return [Proc, nil]
|
|
336
373
|
attr_accessor :on_focus_changed
|
|
337
374
|
|
|
338
|
-
# Internal — use {Component::
|
|
339
|
-
# {#pane}
|
|
375
|
+
# Internal — use {Component::Overlay#open} instead. Adds the overlay to
|
|
376
|
+
# {#pane}; a {Component::Popup} is additionally centered and focused.
|
|
340
377
|
# @api private
|
|
341
|
-
# @param window [Component::
|
|
378
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
342
379
|
# @return [void]
|
|
343
380
|
def add_popup(window)
|
|
344
381
|
check_locked
|
|
@@ -469,12 +506,12 @@ module Tuile
|
|
|
469
506
|
@global_shortcuts.delete(key)
|
|
470
507
|
end
|
|
471
508
|
|
|
472
|
-
# Internal — use {Component::
|
|
509
|
+
# Internal — use {Component::Overlay#close} instead. Removes the overlay
|
|
473
510
|
# from {#pane}, repairs focus, and repaints the scene.
|
|
474
511
|
#
|
|
475
|
-
# Does nothing if the
|
|
512
|
+
# Does nothing if the overlay is not open on this screen.
|
|
476
513
|
# @api private
|
|
477
|
-
# @param window [Component::
|
|
514
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
478
515
|
# @return [void]
|
|
479
516
|
def remove_popup(window)
|
|
480
517
|
check_locked
|
|
@@ -497,10 +534,10 @@ module Tuile
|
|
|
497
534
|
@pane&.on_tree { invalidate _1 }
|
|
498
535
|
end
|
|
499
536
|
|
|
500
|
-
# Internal — use {Component::
|
|
537
|
+
# Internal — use {Component::Overlay#open?} instead.
|
|
501
538
|
# @api private
|
|
502
|
-
# @param window [Component::
|
|
503
|
-
# @return [Boolean] true if this
|
|
539
|
+
# @param window [Component::Overlay] any overlay, modal or not.
|
|
540
|
+
# @return [Boolean] true if this overlay is currently mounted.
|
|
504
541
|
def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
|
|
505
542
|
check_locked
|
|
506
543
|
@pane.has_popup?(window)
|
|
@@ -546,15 +583,20 @@ module Tuile
|
|
|
546
583
|
end
|
|
547
584
|
|
|
548
585
|
# Writes terminal-housekeeping escapes straight to stdout: {#clear},
|
|
549
|
-
# mouse-tracking start/stop, the color-scheme notify toggles,
|
|
550
|
-
# on teardown. Component painting does
|
|
551
|
-
# writes into {#buffer}, which
|
|
552
|
-
#
|
|
553
|
-
# test runner's stdout.
|
|
586
|
+
# mouse-tracking start/stop, the color-scheme notify toggles, the OSC 11
|
|
587
|
+
# background re-probe, cursor-show on teardown. Component painting does
|
|
588
|
+
# *not* go through here anymore — it writes into {#buffer}, which
|
|
589
|
+
# {#repaint} diffs and {#emit}s. {FakeScreen} overrides this (and
|
|
590
|
+
# {#emit}) to capture into `@prints` instead of the test runner's stdout.
|
|
591
|
+
#
|
|
592
|
+
# Flushed, like {#emit}: none of these escapes ends in a newline, and a
|
|
593
|
+
# buffered stdout would hold a *query* — one whose reply the app is
|
|
594
|
+
# waiting on — until the next frame went out.
|
|
554
595
|
# @param args [String] stuff to print.
|
|
555
596
|
# @return [void]
|
|
556
597
|
def print(*args)
|
|
557
598
|
Kernel.print(*args)
|
|
599
|
+
$stdout.flush
|
|
558
600
|
end
|
|
559
601
|
|
|
560
602
|
# Rings the terminal bell ({Ansi::BEL}) — the signal for a keystroke that
|
|
@@ -624,12 +666,22 @@ module Tuile
|
|
|
624
666
|
tiled_invalidated = true
|
|
625
667
|
end
|
|
626
668
|
|
|
627
|
-
# Popups on top:
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
669
|
+
# Popups on top: a layer repaints whole whenever anything *beneath* it —
|
|
670
|
+
# the tiled tree, or a lower popup — repaints, else just its invalidated
|
|
671
|
+
# members. Layer-by-layer rather than one tiled_invalidated bool because
|
|
672
|
+
# the drain loops: a lower popup's repaint cascade re-invalidates
|
|
673
|
+
# children into the *next* iteration (its gap-clearing Layout wipes and
|
|
674
|
+
# re-queues a button, say), and that iteration has no tiled repaint —
|
|
675
|
+
# only the full re-assert of every layer above keeps the stacking order
|
|
676
|
+
# true across iterations (screen_spec pins it with two overlapping
|
|
677
|
+
# popups). Overdraw into the buffer is free (only net-visible cell
|
|
678
|
+
# changes reach the terminal), so reasserting layers is cheap.
|
|
679
|
+
below_repainted = tiled_invalidated
|
|
631
680
|
popups.each do |p|
|
|
632
|
-
|
|
681
|
+
layer_invalidated = false
|
|
682
|
+
p.on_tree { |c| layer_invalidated ||= @invalidated.include?(c) }
|
|
683
|
+
p.on_tree { |c| repaint << c if below_repainted || @invalidated.include?(c) }
|
|
684
|
+
below_repainted ||= layer_invalidated
|
|
633
685
|
end
|
|
634
686
|
|
|
635
687
|
@repainting = repaint.to_set
|
|
@@ -654,25 +706,53 @@ module Tuile
|
|
|
654
706
|
|
|
655
707
|
private
|
|
656
708
|
|
|
657
|
-
#
|
|
658
|
-
#
|
|
659
|
-
#
|
|
660
|
-
#
|
|
661
|
-
#
|
|
662
|
-
#
|
|
663
|
-
#
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
TerminalBackground.detect == :light ? :light : :dark
|
|
709
|
+
# The startup background probe, seeding {#theme} and
|
|
710
|
+
# {#background_color}. Inconclusive detection means `:dark` with no
|
|
711
|
+
# color. Runs in the constructor — the OSC 11 reply arrives on stdin,
|
|
712
|
+
# which is only safe to read before {EventQueue#start_key_thread} owns
|
|
713
|
+
# it. {FakeScreen} overrides this to pin the result, keeping specs
|
|
714
|
+
# deterministic and off the test runner's TTY.
|
|
715
|
+
# @return [TerminalBackground::Result]
|
|
716
|
+
def detect_background
|
|
717
|
+
TerminalBackground.detect || TerminalBackground::Result.new(scheme: :dark, color: nil)
|
|
667
718
|
end
|
|
668
719
|
|
|
720
|
+
# The startup color-depth probe, seeding {#color_depth}. Reads the
|
|
721
|
+
# environment only, so unlike {#detect_background} it has no timing
|
|
722
|
+
# constraint. {FakeScreen} overrides it to pin the result, keeping specs
|
|
723
|
+
# off whatever `COLORTERM` the test runner happens to carry.
|
|
724
|
+
# @return [Symbol]
|
|
725
|
+
def detect_color_depth = ColorDepth.detect
|
|
726
|
+
|
|
669
727
|
# An OS appearance flip arrived (mode-2031 report): remember the
|
|
670
|
-
# scheme
|
|
728
|
+
# scheme, apply the matching member of {#theme_def}, and re-probe for
|
|
729
|
+
# the new background RGB, which the report does not carry.
|
|
730
|
+
#
|
|
731
|
+
# The query goes out from *this* thread — the event-loop thread, which
|
|
732
|
+
# also owns {#emit} — so its bytes can never land inside a frame's
|
|
733
|
+
# synchronized-output batch. The reply comes back through the key
|
|
734
|
+
# thread as an {EventQueue::BackgroundColorEvent}.
|
|
671
735
|
# @param scheme [Symbol] `:dark` or `:light`.
|
|
672
736
|
# @return [void]
|
|
673
737
|
def on_color_scheme(scheme)
|
|
674
738
|
@color_scheme = scheme
|
|
675
739
|
self.theme = @theme_def.for(@color_scheme)
|
|
740
|
+
print TerminalBackground::QUERY
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# The re-probe answered: adopt the color and restyle, since an app's
|
|
744
|
+
# background-derived tints are now a scheme behind. Deliberately keeps
|
|
745
|
+
# the previous color until the reply lands rather than blanking it on
|
|
746
|
+
# the flip — a terminal that reports mode-2031 flips but not OSC 11
|
|
747
|
+
# would otherwise lose the color it gave us at startup, permanently.
|
|
748
|
+
# @param color [Color]
|
|
749
|
+
# @return [void]
|
|
750
|
+
def on_background_color(color)
|
|
751
|
+
return if @background_color == color
|
|
752
|
+
|
|
753
|
+
@background_color = color
|
|
754
|
+
@pane&.on_tree { _1.__send__(:on_theme_changed) }
|
|
755
|
+
needs_full_repaint
|
|
676
756
|
end
|
|
677
757
|
|
|
678
758
|
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
@@ -802,6 +882,8 @@ module Tuile
|
|
|
802
882
|
layout
|
|
803
883
|
when EventQueue::ColorSchemeEvent
|
|
804
884
|
on_color_scheme(event.scheme)
|
|
885
|
+
when EventQueue::BackgroundColorEvent
|
|
886
|
+
on_background_color(event.color)
|
|
805
887
|
when EventQueue::EmptyQueueEvent
|
|
806
888
|
repaint
|
|
807
889
|
when Proc
|