tuile 0.8.0 → 0.9.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 +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/lib/tuile/buffer.rb +100 -31
- data/lib/tuile/component/button.rb +1 -9
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +7 -15
- data/lib/tuile/component/layout.rb +0 -9
- data/lib/tuile/component/list.rb +0 -26
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/popup.rb +70 -75
- data/lib/tuile/component/text_view.rb +0 -23
- data/lib/tuile/component/window.rb +57 -75
- data/lib/tuile/component.rb +1 -60
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +8 -5
- data/lib/tuile/screen_pane.rb +5 -3
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +195 -223
- metadata +12 -4
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
|
@@ -4,18 +4,30 @@ module Tuile
|
|
|
4
4
|
class Component
|
|
5
5
|
# An overlay that wraps any {Component} as its content. Popup itself
|
|
6
6
|
# paints nothing — it's a transparent host that handles its lifecycle
|
|
7
|
-
# ({#open} / {#close} / {#open?}, ESC/q to close) and
|
|
8
|
-
#
|
|
7
|
+
# ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
|
|
8
|
+
# {#size} the {Screen} applies.
|
|
9
|
+
#
|
|
10
|
+
# The popup does *not* size itself to its content. Its box is declared by
|
|
11
|
+
# {#size} — a {Fraction} (resolved against the screen every layout pass, so
|
|
12
|
+
# it tracks resize) or an absolute {Size} (clamped to the screen). The
|
|
13
|
+
# default is {Fraction::HALF}: half the screen, centered. The wrapped
|
|
14
|
+
# content then fills that box and handles its own overflow by wrapping and
|
|
15
|
+
# scrolling, so use content that can — a {Component::TextView} or
|
|
16
|
+
# {Component::TextArea} — for anything longer than fits. A
|
|
17
|
+
# {Component::Label} only truncates.
|
|
9
18
|
#
|
|
10
19
|
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
11
20
|
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
12
|
-
# that floats above the content (still painted on top
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
21
|
+
# that floats above the content (still painted on top) without taking focus
|
|
22
|
+
# or capturing input — the caller positions it (via {#rect=}) and drives it
|
|
23
|
+
# from app code. That is the building block for an autocomplete/slash-command
|
|
24
|
+
# list anchored to a {Component::TextField} or {Component::TextArea} caret:
|
|
25
|
+
# typing keeps focus (and the cursor) in the input, an
|
|
26
|
+
# {Component::TextInput#on_change} listener refills the list, and an
|
|
27
|
+
# {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
|
|
28
|
+
# Such a caller owns the list data, so it sizes the overlay itself
|
|
29
|
+
# (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
|
|
30
|
+
# caller-decides, top-down.
|
|
19
31
|
#
|
|
20
32
|
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
21
33
|
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
@@ -36,22 +48,43 @@ module Tuile
|
|
|
36
48
|
include Component::HasContent
|
|
37
49
|
|
|
38
50
|
# @param content [Component, nil] initial content; can be set later via
|
|
39
|
-
# {#content=}.
|
|
51
|
+
# {#content=}. The content fills the popup's {#rect}; it does not
|
|
52
|
+
# determine the popup's size.
|
|
40
53
|
# @param modal [Boolean] true (default) for a centered, focus-grabbing,
|
|
41
54
|
# input-capturing modal; false for a non-modal overlay the caller
|
|
42
55
|
# positions and drives (see the class docs).
|
|
43
|
-
|
|
56
|
+
# @param size [Size, Fraction] the popup's size, applied top-down. A
|
|
57
|
+
# {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
58
|
+
# is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
59
|
+
def initialize(content: nil, modal: true, size: Fraction::HALF)
|
|
44
60
|
super()
|
|
45
61
|
@modal = modal
|
|
62
|
+
@size = size
|
|
46
63
|
@content = nil
|
|
47
64
|
self.content = content unless content.nil?
|
|
65
|
+
reposition
|
|
48
66
|
end
|
|
49
67
|
|
|
68
|
+
# @return [Size, Fraction] the popup's declared size. See {#size=}.
|
|
69
|
+
attr_reader :size
|
|
70
|
+
|
|
50
71
|
# @return [Boolean] whether this popup is modal. See {#initialize}.
|
|
51
72
|
def modal? = @modal
|
|
52
73
|
|
|
53
74
|
def focusable? = true
|
|
54
75
|
|
|
76
|
+
# Sets the popup's size and repositions it. Accepts a {Fraction}
|
|
77
|
+
# (resolved against the screen every layout pass, so it tracks resize) or
|
|
78
|
+
# an absolute {Size} (clamped to the screen). This is **authoritative**,
|
|
79
|
+
# not a preference: the screen applies exactly what you ask for (clamped),
|
|
80
|
+
# with no negotiation — a popup has no siblings to compete with.
|
|
81
|
+
# @param new_size [Size, Fraction]
|
|
82
|
+
# @return [void]
|
|
83
|
+
def size=(new_size)
|
|
84
|
+
@size = new_size
|
|
85
|
+
reposition
|
|
86
|
+
end
|
|
87
|
+
|
|
55
88
|
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
56
89
|
# open popup shrinks or moves so its new rect no longer covers the cells
|
|
57
90
|
# it previously painted. A popup overdraws the scene without clipping and
|
|
@@ -67,20 +100,21 @@ module Tuile
|
|
|
67
100
|
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
68
101
|
end
|
|
69
102
|
|
|
70
|
-
# Mounts this popup on the {Screen}
|
|
71
|
-
#
|
|
72
|
-
# grown or shrunk while closed picks up the new size.
|
|
103
|
+
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
104
|
+
# current screen first.
|
|
73
105
|
# @return [void]
|
|
74
106
|
def open
|
|
75
|
-
|
|
107
|
+
reposition
|
|
76
108
|
screen.add_popup(self)
|
|
77
109
|
end
|
|
78
110
|
|
|
79
111
|
# Constructs and opens a popup in one call.
|
|
80
112
|
# @param content [Component, nil]
|
|
113
|
+
# @param modal [Boolean] see {#initialize}.
|
|
114
|
+
# @param size [Size, Fraction] see {#initialize}.
|
|
81
115
|
# @return [Popup] the opened popup.
|
|
82
|
-
def self.open(content: nil)
|
|
83
|
-
Popup.new(content: content).tap(&:open)
|
|
116
|
+
def self.open(content: nil, modal: true, size: Fraction::HALF)
|
|
117
|
+
Popup.new(content: content, modal: modal, size: size).tap(&:open)
|
|
84
118
|
end
|
|
85
119
|
|
|
86
120
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
@@ -94,44 +128,29 @@ module Tuile
|
|
|
94
128
|
screen.has_popup?(self)
|
|
95
129
|
end
|
|
96
130
|
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
131
|
+
# Re-resolves {#size} against the current screen and repositions the popup
|
|
132
|
+
# *itself* (this is not laying out content — the popup's own rect): a
|
|
133
|
+
# modal popup recenters; a non-modal overlay keeps its caller-assigned
|
|
134
|
+
# top-left (only its size follows the screen). Called on {#open}, on
|
|
135
|
+
# {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
|
|
136
|
+
# SIGWINCH).
|
|
137
|
+
#
|
|
138
|
+
# The final rect is computed and assigned in one step rather than sizing
|
|
139
|
+
# at the origin and then centering: the intermediate origin rect rarely
|
|
140
|
+
# covers the previous one, which would make {#rect=}'s shrink/move
|
|
141
|
+
# detection fire a full repaint on every resize.
|
|
100
142
|
# @return [void]
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Defers to the content's {Component#popup_max_height} advice when it
|
|
107
|
-
# gives one, else defaults to 12. Override in a subclass to allow
|
|
108
|
-
# taller popups regardless of content.
|
|
109
|
-
def max_height = @content&.popup_max_height || 12
|
|
110
|
-
|
|
111
|
-
# @return [Integer] min height the popup occupies even when its content
|
|
112
|
-
# is shorter. Defers to the content's {Component#popup_min_height}
|
|
113
|
-
# advice when it gives one, else defaults to 0 (size purely to
|
|
114
|
-
# content) — so a {Component::LogWindow} stays readable while only a
|
|
115
|
-
# few lines are in without callers wiring up a subclass. Override in a
|
|
116
|
-
# subclass to keep any popup from collapsing to a couple of rows.
|
|
117
|
-
# Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
|
|
118
|
-
def min_height = @content&.popup_min_height || 0
|
|
119
|
-
|
|
120
|
-
# Sets the popup's content and auto-sizes the popup to fit.
|
|
121
|
-
# @param new_content [Component, nil]
|
|
122
|
-
def content=(new_content)
|
|
123
|
-
super
|
|
124
|
-
update_rect unless new_content.nil?
|
|
143
|
+
def reposition
|
|
144
|
+
size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
|
|
145
|
+
r = Rect.new(rect.left, rect.top, size.width, size.height)
|
|
146
|
+
r = r.centered(screen.size) if modal?
|
|
147
|
+
self.rect = r
|
|
125
148
|
end
|
|
126
149
|
|
|
127
|
-
#
|
|
128
|
-
# natural size changes — e.g. a {Label}'s `text=`, a {List}'s
|
|
129
|
-
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
130
|
-
# recomputes its {Component#content_size} and the change bubbles here).
|
|
131
|
-
# @param _child [Component]
|
|
150
|
+
# Recenters the popup on the screen, preserving its current width/height.
|
|
132
151
|
# @return [void]
|
|
133
|
-
def
|
|
134
|
-
|
|
152
|
+
def center
|
|
153
|
+
self.rect = rect.centered(screen.size)
|
|
135
154
|
end
|
|
136
155
|
|
|
137
156
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
@@ -164,30 +183,6 @@ module Tuile
|
|
|
164
183
|
def layout(content)
|
|
165
184
|
content.rect = rect
|
|
166
185
|
end
|
|
167
|
-
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
# Recompute width/height from {#content}'s natural size and recenter
|
|
171
|
-
# if currently open. Called whenever content is (re)assigned.
|
|
172
|
-
#
|
|
173
|
-
# Computes the final (centered) rect and assigns it in one step rather
|
|
174
|
-
# than positioning at the origin and then centering: the intermediate
|
|
175
|
-
# origin rect rarely covers the previous one, which would make
|
|
176
|
-
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
177
|
-
# @return [void]
|
|
178
|
-
def update_rect
|
|
179
|
-
ceiling = screen.size.height * 4 / 5
|
|
180
|
-
size = @content.content_size.clamp_height(max_height)
|
|
181
|
-
size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
|
|
182
|
-
floor = min_height.clamp(0, ceiling)
|
|
183
|
-
size = Size.new(size.width, floor) if size.height < floor
|
|
184
|
-
# A non-modal overlay is positioned by the caller, so an open one keeps
|
|
185
|
-
# its current top-left when its content resizes; a modal popup recenters.
|
|
186
|
-
origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
|
|
187
|
-
r = Rect.new(origin.x, origin.y, size.width, size.height)
|
|
188
|
-
r = r.centered(screen.size) if open? && modal?
|
|
189
|
-
self.rect = r
|
|
190
|
-
end
|
|
191
186
|
end
|
|
192
187
|
end
|
|
193
188
|
end
|
|
@@ -122,7 +122,6 @@ module Tuile
|
|
|
122
122
|
rewrap
|
|
123
123
|
update_top_line_if_auto_scroll
|
|
124
124
|
invalidate
|
|
125
|
-
self.content_size = compute_content_size
|
|
126
125
|
end
|
|
127
126
|
|
|
128
127
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -191,7 +190,6 @@ module Tuile
|
|
|
191
190
|
@text = nil
|
|
192
191
|
update_top_line_if_auto_scroll
|
|
193
192
|
invalidate
|
|
194
|
-
self.content_size = compute_content_size
|
|
195
193
|
end
|
|
196
194
|
|
|
197
195
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -266,7 +264,6 @@ module Tuile
|
|
|
266
264
|
@top_line = top_line_max if @top_line > top_line_max
|
|
267
265
|
update_top_line_if_auto_scroll
|
|
268
266
|
invalidate
|
|
269
|
-
self.content_size = compute_content_size
|
|
270
267
|
end
|
|
271
268
|
|
|
272
269
|
# Replaces a contiguous range of hard lines with the parsed content
|
|
@@ -327,7 +324,6 @@ module Tuile
|
|
|
327
324
|
@top_line = top_line_max if @top_line > top_line_max
|
|
328
325
|
update_top_line_if_auto_scroll
|
|
329
326
|
invalidate
|
|
330
|
-
self.content_size = compute_content_size
|
|
331
327
|
end
|
|
332
328
|
|
|
333
329
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -386,13 +382,6 @@ module Tuile
|
|
|
386
382
|
|
|
387
383
|
def tab_stop? = true
|
|
388
384
|
|
|
389
|
-
# @return [Size] longest hard-line's display width × number of hard
|
|
390
|
-
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
391
|
-
# be circular (width depends on width). Empty text returns
|
|
392
|
-
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
393
|
-
# {#append}, so reads are O(1).
|
|
394
|
-
attr_reader :content_size
|
|
395
|
-
|
|
396
385
|
# @param key [String]
|
|
397
386
|
# @return [Boolean]
|
|
398
387
|
def handle_key(key)
|
|
@@ -544,7 +533,6 @@ module Tuile
|
|
|
544
533
|
@top_line = top_line_max if @top_line > top_line_max
|
|
545
534
|
update_top_line_if_auto_scroll
|
|
546
535
|
invalidate
|
|
547
|
-
self.content_size = compute_content_size
|
|
548
536
|
end
|
|
549
537
|
|
|
550
538
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -570,7 +558,6 @@ module Tuile
|
|
|
570
558
|
@top_line = top_line_max if @top_line > top_line_max
|
|
571
559
|
update_top_line_if_auto_scroll
|
|
572
560
|
invalidate
|
|
573
|
-
self.content_size = compute_content_size
|
|
574
561
|
end
|
|
575
562
|
|
|
576
563
|
# Verbatim append into `region`.
|
|
@@ -610,7 +597,6 @@ module Tuile
|
|
|
610
597
|
@top_line = top_line_max if @top_line > top_line_max
|
|
611
598
|
update_top_line_if_auto_scroll
|
|
612
599
|
invalidate
|
|
613
|
-
self.content_size = compute_content_size
|
|
614
600
|
end
|
|
615
601
|
|
|
616
602
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -632,7 +618,6 @@ module Tuile
|
|
|
632
618
|
@top_line = top_line_max if @top_line > top_line_max
|
|
633
619
|
update_top_line_if_auto_scroll
|
|
634
620
|
invalidate
|
|
635
|
-
self.content_size = compute_content_size
|
|
636
621
|
end
|
|
637
622
|
|
|
638
623
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -658,7 +643,6 @@ module Tuile
|
|
|
658
643
|
@top_line = top_line_max if @top_line > top_line_max
|
|
659
644
|
update_top_line_if_auto_scroll
|
|
660
645
|
invalidate
|
|
661
|
-
self.content_size = compute_content_size
|
|
662
646
|
end
|
|
663
647
|
|
|
664
648
|
# Adjusts region line counts after a {@hard_lines} splice that
|
|
@@ -832,13 +816,6 @@ module Tuile
|
|
|
832
816
|
StyledString.new(spans)
|
|
833
817
|
end
|
|
834
818
|
|
|
835
|
-
# @return [Size] {#content_size} computed from {@hard_lines}.
|
|
836
|
-
def compute_content_size
|
|
837
|
-
return Size::ZERO if @hard_lines.empty?
|
|
838
|
-
|
|
839
|
-
Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
|
|
840
|
-
end
|
|
841
|
-
|
|
842
819
|
# @return [Integer] column width available for wrapped text — viewport
|
|
843
820
|
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
844
821
|
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
@@ -20,38 +20,46 @@ module Tuile
|
|
|
20
20
|
@border_right = 1
|
|
21
21
|
@caption = caption
|
|
22
22
|
@content = nil
|
|
23
|
-
# Optional bottom-row
|
|
24
|
-
#
|
|
23
|
+
# Optional bottom-row widget slot (e.g. a search field), spanning the
|
|
24
|
+
# full inner width; and optional bottom-border chrome text embedded in
|
|
25
|
+
# the border line (mutually exclusive — the component, when present,
|
|
26
|
+
# occupies the row and hides the text).
|
|
25
27
|
@footer = nil
|
|
26
|
-
@
|
|
27
|
-
update_content_size
|
|
28
|
+
@footer_text = StyledString::EMPTY
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def focusable? = true
|
|
31
32
|
|
|
32
|
-
# @return [Component, nil] optional component
|
|
33
|
-
# row.
|
|
33
|
+
# @return [Component, nil] optional focusable component occupying the
|
|
34
|
+
# bottom border row, always spanning the full inner width.
|
|
34
35
|
attr_reader :footer
|
|
35
36
|
|
|
36
|
-
# @return [
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
attr_reader :
|
|
40
|
-
|
|
41
|
-
# Sets the
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
# @return [StyledString] optional chrome embedded into the bottom border
|
|
38
|
+
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
39
|
+
# whenever a {#footer} component is present.
|
|
40
|
+
attr_reader :footer_text
|
|
41
|
+
|
|
42
|
+
# Sets the bottom-border chrome. Accepts a `String` (parsed via
|
|
43
|
+
# {StyledString.parse}), a {StyledString}, or `nil` (clears it). The text
|
|
44
|
+
# embeds into the bottom border at its own width with the border's dashes
|
|
45
|
+
# filling the remainder, clipped to the inner width — border decoration,
|
|
46
|
+
# not a component (not focusable). Hidden whenever a {#footer} component
|
|
47
|
+
# occupies the bottom row (see the precedence note on {#footer=}).
|
|
48
|
+
# @param text [String, StyledString, nil]
|
|
49
|
+
def footer_text=(text)
|
|
50
|
+
new_text = StyledString.parse(text)
|
|
51
|
+
return if @footer_text == new_text
|
|
52
|
+
|
|
53
|
+
@footer_text = new_text
|
|
54
|
+
invalidate # repaint the bottom border line
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets the bottom-row widget slot. The footer occupies the bottom border
|
|
58
|
+
# row, spanning the full inner width, and is positioned automatically;
|
|
59
|
+
# pass `nil` to remove.
|
|
60
|
+
#
|
|
61
|
+
# Precedence: a footer component present hides {#footer_text}; absent, the
|
|
62
|
+
# text embeds into the bottom border. No window needs both at once.
|
|
55
63
|
#
|
|
56
64
|
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
57
65
|
# pointers, invalidates the old/new components and the window border, and
|
|
@@ -122,36 +130,6 @@ module Tuile
|
|
|
122
130
|
def caption=(new_caption)
|
|
123
131
|
@caption = new_caption
|
|
124
132
|
invalidate
|
|
125
|
-
update_content_size
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Sets the new content. Also recomputes the window's natural size.
|
|
129
|
-
# @param new_content [Component, nil]
|
|
130
|
-
def content=(new_content)
|
|
131
|
-
super
|
|
132
|
-
update_content_size
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
136
|
-
# size changes, and folds a content resize into the window's own
|
|
137
|
-
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
138
|
-
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
139
|
-
# participate in the window's {#content_size}: it is decoration
|
|
140
|
-
# overlaying the border, and must not drive the window's size — if it
|
|
141
|
-
# doesn't fit, it is clipped to the inner width.
|
|
142
|
-
# @param child [Component]
|
|
143
|
-
# @return [void]
|
|
144
|
-
def on_child_content_size_changed(child)
|
|
145
|
-
if child.equal?(@footer)
|
|
146
|
-
old_rect = @footer.rect
|
|
147
|
-
layout_footer
|
|
148
|
-
# Repaint on any footer geometry change: a shrinking footer vacates
|
|
149
|
-
# border cells that must be re-dashed (a growing one merely
|
|
150
|
-
# overdraws, but distinguishing isn't worth the code).
|
|
151
|
-
invalidate if @footer.rect != old_rect
|
|
152
|
-
else
|
|
153
|
-
update_content_size
|
|
154
|
-
end
|
|
155
133
|
end
|
|
156
134
|
|
|
157
135
|
# Fully repaints the window: both frame and contents.
|
|
@@ -178,7 +156,6 @@ module Tuile
|
|
|
178
156
|
super
|
|
179
157
|
# The shortcut key is shown in the caption — repaint.
|
|
180
158
|
invalidate
|
|
181
|
-
update_content_size
|
|
182
159
|
end
|
|
183
160
|
|
|
184
161
|
protected
|
|
@@ -213,7 +190,26 @@ module Tuile
|
|
|
213
190
|
buf.set_char(left, top + dy, "│", bar)
|
|
214
191
|
buf.set_char(left + w - 1, top + dy, "│", bar)
|
|
215
192
|
end
|
|
216
|
-
buf.set_line(left, top + h - 1,
|
|
193
|
+
buf.set_line(left, top + h - 1, bottom_border(inner_w, fg)) if h >= 2
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Builds the bottom border line. The corners take the border color; the
|
|
197
|
+
# interior is plain dashes when a {#footer} component occupies the row
|
|
198
|
+
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
199
|
+
# {#footer_text} embedded at its own width — keeping the text's own
|
|
200
|
+
# styling — with dashes filling the remainder up to the inner width.
|
|
201
|
+
# @param inner_w [Integer] the border's interior width.
|
|
202
|
+
# @param fg [Color, nil] the active-border color, or nil when inactive.
|
|
203
|
+
# @return [StyledString]
|
|
204
|
+
def bottom_border(inner_w, fg)
|
|
205
|
+
interior =
|
|
206
|
+
if @footer || @footer_text.empty?
|
|
207
|
+
StyledString.styled("─" * inner_w, fg: fg)
|
|
208
|
+
else
|
|
209
|
+
embedded = @footer_text.slice(0, inner_w)
|
|
210
|
+
embedded + StyledString.styled("─" * (inner_w - embedded.display_width), fg: fg)
|
|
211
|
+
end
|
|
212
|
+
StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
|
|
217
213
|
end
|
|
218
214
|
|
|
219
215
|
# The caption text as it appears in the rendered border, including the
|
|
@@ -226,28 +222,14 @@ module Tuile
|
|
|
226
222
|
|
|
227
223
|
private
|
|
228
224
|
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
# window with no content or caption sizes to `Size.new(2, 2)` (bare
|
|
233
|
-
# border).
|
|
234
|
-
# @return [void]
|
|
235
|
-
def update_content_size
|
|
236
|
-
inner_w = [content&.content_size&.width || 0, frame_caption.length].max
|
|
237
|
-
inner_h = content&.content_size&.height || 0
|
|
238
|
-
self.content_size = Size.new(inner_w + 2, inner_h + 2)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# Positions the footer over the bottom border row, with its width
|
|
242
|
-
# resolved by {#footer_sizing} against the inner width. A
|
|
243
|
-
# {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
|
|
244
|
-
# rect — i.e. it is invisible, as if never assigned.
|
|
225
|
+
# Positions the footer over the bottom border row, spanning the full
|
|
226
|
+
# inner width (the only dimension a bottom-row widget needs — the window
|
|
227
|
+
# already knows it).
|
|
245
228
|
# @return [void]
|
|
246
229
|
def layout_footer
|
|
247
230
|
return if @footer.nil? || rect.empty?
|
|
248
231
|
|
|
249
|
-
|
|
250
|
-
width = @footer_sizing.resolve(available, @footer.content_size.width)
|
|
232
|
+
width = [rect.width - 2, 0].max
|
|
251
233
|
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
252
234
|
end
|
|
253
235
|
end
|
data/lib/tuile/component.rb
CHANGED
|
@@ -12,7 +12,6 @@ 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
15
|
@on_theme_changed = nil
|
|
17
16
|
end
|
|
18
17
|
|
|
@@ -252,30 +251,6 @@ module Tuile
|
|
|
252
251
|
end
|
|
253
252
|
end
|
|
254
253
|
|
|
255
|
-
# The {Size} big enough to show the entire component contents without
|
|
256
|
-
# scrolling. Plain components have no intrinsic content and report
|
|
257
|
-
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
258
|
-
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
259
|
-
# their mutators, so reads are O(1). Used by callers like
|
|
260
|
-
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
261
|
-
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
262
|
-
# @return [Size]
|
|
263
|
-
attr_reader :content_size
|
|
264
|
-
|
|
265
|
-
# Called by a child component whose {#content_size} just changed (fired
|
|
266
|
-
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
267
|
-
# container is not size-coupled to its children. Containers that derive
|
|
268
|
-
# their own natural size or child layout from a child's natural size
|
|
269
|
-
# override this (e.g. {Component::Window} re-lays-out a
|
|
270
|
-
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
271
|
-
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
272
|
-
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
273
|
-
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
274
|
-
# keeps changing, and stops where it doesn't.
|
|
275
|
-
# @param child [Component] the resized direct child.
|
|
276
|
-
# @return [void]
|
|
277
|
-
def on_child_content_size_changed(child); end
|
|
278
|
-
|
|
279
254
|
# Where the hardware terminal cursor should sit when this component is the
|
|
280
255
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
281
256
|
# {Screen} positions the hardware cursor after each repaint cycle by
|
|
@@ -288,49 +263,15 @@ module Tuile
|
|
|
288
263
|
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
289
264
|
def keyboard_hint = ""
|
|
290
265
|
|
|
291
|
-
# Advice to a wrapping {Component::Popup} on the minimum height this
|
|
292
|
-
# component prefers to occupy when shown in a popup. `nil` (the default)
|
|
293
|
-
# means no preference — the popup uses its own {Component::Popup#min_height}.
|
|
294
|
-
# Override in a content component that should not collapse to a couple of
|
|
295
|
-
# rows when sparse (e.g. {Component::LogWindow}).
|
|
296
|
-
# @return [Integer, nil]
|
|
297
|
-
def popup_min_height = nil
|
|
298
|
-
|
|
299
|
-
# Advice to a wrapping {Component::Popup} on the maximum height this
|
|
300
|
-
# component may grow to when shown in a popup. `nil` (the default) means
|
|
301
|
-
# no preference — the popup uses its own {Component::Popup#max_height}.
|
|
302
|
-
# @return [Integer, nil]
|
|
303
|
-
def popup_max_height = nil
|
|
304
|
-
|
|
305
266
|
protected
|
|
306
267
|
|
|
307
|
-
# @
|
|
268
|
+
# @return [Component, nil]
|
|
308
269
|
attr_writer :parent
|
|
309
270
|
|
|
310
271
|
# Called whenever the component width changes. Does nothing by default.
|
|
311
272
|
# @return [void]
|
|
312
273
|
def on_width_changed; end
|
|
313
274
|
|
|
314
|
-
# Memoizes the component's natural size and notifies {#parent} via
|
|
315
|
-
# {#on_child_content_size_changed} when the value actually changed.
|
|
316
|
-
# Subclasses call this from their content mutators (`text=`, `add_lines`,
|
|
317
|
-
# `caption=`, …) instead of caching ad-hoc.
|
|
318
|
-
#
|
|
319
|
-
# Call this as the *last* step of a mutator: the parent hook may
|
|
320
|
-
# reentrantly reposition this component (assign {#rect} — e.g. {Window}
|
|
321
|
-
# re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
|
|
322
|
-
# triggers {#on_width_changed} and {#repaint}-related recomputation, so
|
|
323
|
-
# all internal state must already be consistent.
|
|
324
|
-
# @param new_size [Size]
|
|
325
|
-
# @return [void]
|
|
326
|
-
def content_size=(new_size)
|
|
327
|
-
raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
|
|
328
|
-
return if @content_size == new_size
|
|
329
|
-
|
|
330
|
-
@content_size = new_size
|
|
331
|
-
parent&.on_child_content_size_changed(self)
|
|
332
|
-
end
|
|
333
|
-
|
|
334
275
|
# Invalidates the component: {Screen} records this component as
|
|
335
276
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
336
277
|
#
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -47,10 +47,13 @@ module Tuile
|
|
|
47
47
|
latch.wait
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Schedules `block` to fire on the event-loop thread
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
50
|
+
# Schedules `block` to fire on the event-loop thread every `seconds`,
|
|
51
|
+
# passing a 0-based monotonically increasing tick counter. The interval is
|
|
52
|
+
# in **seconds** — the conventional scheduling unit (`sleep`,
|
|
53
|
+
# `Concurrent::TimerTask#execution_interval`, …) — so `tick(0.2)` fires five
|
|
54
|
+
# times a second. Use it for periodic UI refresh from a background task
|
|
55
|
+
# (poll a status, redraw a clock). For animation, where frames-per-second
|
|
56
|
+
# is the natural unit, {#tick_fps} reads better.
|
|
54
57
|
#
|
|
55
58
|
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
56
59
|
# stop it.
|
|
@@ -64,19 +67,37 @@ module Tuile
|
|
|
64
67
|
# ({Concurrent}.global_timer_set) — adding more tickers does not add more
|
|
65
68
|
# threads, just more work on the shared scheduler.
|
|
66
69
|
#
|
|
70
|
+
# @param seconds [Numeric] interval between firings, must be positive.
|
|
71
|
+
# Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
|
|
72
|
+
# @yield [tick] called on the event-loop thread each firing.
|
|
73
|
+
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
74
|
+
# @yieldreturn [void]
|
|
75
|
+
# @return [Ticker]
|
|
76
|
+
def tick(seconds, &block)
|
|
77
|
+
raise ArgumentError, "block required" unless block
|
|
78
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
79
|
+
raise ArgumentError, "seconds must be a positive Numeric, got #{seconds.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Ticker.new(self, seconds, block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Frames-per-second convenience over {#tick}: `tick_fps(15)` is exactly
|
|
86
|
+
# `tick(1.0 / 15)`. Reads naturally for animation (a `/-\|` spinner, a
|
|
87
|
+
# progress pulse) where you think in frames, not intervals.
|
|
67
88
|
# @param fps [Numeric] firings per second, must be positive. Fractional
|
|
68
|
-
# values are fine (`
|
|
89
|
+
# values are fine (`tick_fps(0.5)` ⇒ one firing every two seconds).
|
|
69
90
|
# @yield [tick] called on the event-loop thread each firing.
|
|
70
91
|
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
71
92
|
# @yieldreturn [void]
|
|
72
93
|
# @return [Ticker]
|
|
73
|
-
def
|
|
94
|
+
def tick_fps(fps, &block)
|
|
74
95
|
raise ArgumentError, "block required" unless block
|
|
75
96
|
unless fps.is_a?(Numeric) && fps.positive?
|
|
76
97
|
raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
|
|
77
98
|
end
|
|
78
99
|
|
|
79
|
-
|
|
100
|
+
tick(1.0 / fps, &block)
|
|
80
101
|
end
|
|
81
102
|
|
|
82
103
|
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
@@ -227,9 +248,9 @@ module Tuile
|
|
|
227
248
|
# ({Screen#on_error} for the default Tuile setup).
|
|
228
249
|
class Ticker
|
|
229
250
|
# @param event_queue [EventQueue] queue to dispatch tick calls onto.
|
|
230
|
-
# @param
|
|
251
|
+
# @param interval [Numeric] seconds between firings (positive).
|
|
231
252
|
# @param block [Proc] called as `block.call(tick_count)` on each fire.
|
|
232
|
-
def initialize(event_queue,
|
|
253
|
+
def initialize(event_queue, interval, block)
|
|
233
254
|
@event_queue = event_queue
|
|
234
255
|
@block = block
|
|
235
256
|
@tick = 0
|
|
@@ -239,7 +260,7 @@ module Tuile
|
|
|
239
260
|
# one-shot guard against double-shutdown and well-defined visibility
|
|
240
261
|
# on non-MRI Rubies.
|
|
241
262
|
@cancelled = Concurrent::AtomicBoolean.new(false)
|
|
242
|
-
@timer = Concurrent::TimerTask.new(execution_interval:
|
|
263
|
+
@timer = Concurrent::TimerTask.new(execution_interval: interval) do
|
|
243
264
|
@event_queue.submit { fire }
|
|
244
265
|
end
|
|
245
266
|
@timer.execute
|