tuile 0.8.0 → 0.10.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 +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- 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 +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
|
@@ -3,31 +3,25 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
6
|
-
# vertically. Shape-wise a hybrid between {Label} (string
|
|
7
|
-
#
|
|
6
|
+
# vertically. Shape-wise a hybrid between {Label} (string content via
|
|
7
|
+
# {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
|
|
8
8
|
#
|
|
9
|
-
# Text is
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# ANSI
|
|
13
|
-
#
|
|
14
|
-
# so embedded ANSI is honored) or a {StyledString} directly; {#text}
|
|
15
|
-
# always returns the {StyledString}.
|
|
9
|
+
# Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
|
|
10
|
+
# are word-wrapped via {StyledString#wrap} with style spans preserved across
|
|
11
|
+
# wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
|
|
12
|
+
# honoring embedded ANSI) or a {StyledString}; {#text} always returns the
|
|
13
|
+
# {StyledString}.
|
|
16
14
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# {#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# caller streaming reformattable content (e.g. partially-rendered
|
|
25
|
-
# Markdown that may need to retract its last paragraph) can replace
|
|
26
|
-
# the tail without rewriting the whole text. Turn on {#auto_scroll}
|
|
27
|
-
# to keep the latest content in view.
|
|
15
|
+
# Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
|
|
16
|
+
# a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
|
|
17
|
+
# {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
|
|
18
|
+
# {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
|
|
19
|
+
# reformattable content can retract and rewrite it; {#replace} / {#insert}
|
|
20
|
+
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
21
|
+
# in view.
|
|
28
22
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
23
|
+
# Meant to be the content of a {Window} — focus indication and keyboard-hint
|
|
24
|
+
# surfacing rely on the surrounding window chrome.
|
|
31
25
|
class TextView < Component
|
|
32
26
|
def initialize
|
|
33
27
|
super
|
|
@@ -55,21 +49,14 @@ module Tuile
|
|
|
55
49
|
@auto_scroll = false
|
|
56
50
|
@follow = true
|
|
57
51
|
@scrollbar_visibility = :gone
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# code that never calls {#create_region} sees the same behavior as
|
|
61
|
-
# before (a single region containing everything); apps that do call
|
|
62
|
-
# {#create_region} stack additional regions at the spatial tail.
|
|
52
|
+
# Always ≥1 region; the implicit default owns any hard lines no
|
|
53
|
+
# app-created region claims. See {Region}.
|
|
63
54
|
@regions = [Region.send(:new, self)]
|
|
64
55
|
end
|
|
65
56
|
|
|
66
|
-
# @return [StyledString] the current text
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# whole buffer; the joined {StyledString} returned here is
|
|
70
|
-
# reconstructed on first read after a mutation and cached, so
|
|
71
|
-
# repeated reads are O(1) but the first read after {#append} pays
|
|
72
|
-
# O(total spans).
|
|
57
|
+
# @return [StyledString] the current text (empty by default). Rebuilt
|
|
58
|
+
# lazily on the first read after a mutation (O(total spans)), then
|
|
59
|
+
# cached — repeated reads are O(1).
|
|
73
60
|
def text
|
|
74
61
|
@text ||= build_text
|
|
75
62
|
end
|
|
@@ -122,7 +109,6 @@ module Tuile
|
|
|
122
109
|
rewrap
|
|
123
110
|
update_top_line_if_auto_scroll
|
|
124
111
|
invalidate
|
|
125
|
-
self.content_size = compute_content_size
|
|
126
112
|
end
|
|
127
113
|
|
|
128
114
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -145,19 +131,12 @@ module Tuile
|
|
|
145
131
|
# @return [Boolean] true iff {#text} is empty (no hard lines).
|
|
146
132
|
def empty? = @hard_lines.empty?
|
|
147
133
|
|
|
148
|
-
# Appends `str` verbatim. Embedded `\n`
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
155
|
-
#
|
|
156
|
-
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
157
|
-
# previously last hard line is re-wrapped (because the extension may
|
|
158
|
-
# cause it to wrap differently), any additional hard lines created by
|
|
159
|
-
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
160
|
-
# and rebuilt on demand.
|
|
134
|
+
# Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
|
|
135
|
+
# the text is concatenated onto the current last hard line. Designed for
|
|
136
|
+
# streaming use (feed each partial chunk straight in). Accepts the same
|
|
137
|
+
# input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
|
|
138
|
+
# line" pattern use {#add_line}. Cost is O(appended + width of the last
|
|
139
|
+
# hard line), which is re-wrapped since the extension may wrap differently.
|
|
161
140
|
# @param str [String, StyledString, nil]
|
|
162
141
|
# @return [void]
|
|
163
142
|
def append(str)
|
|
@@ -191,7 +170,6 @@ module Tuile
|
|
|
191
170
|
@text = nil
|
|
192
171
|
update_top_line_if_auto_scroll
|
|
193
172
|
invalidate
|
|
194
|
-
self.content_size = compute_content_size
|
|
195
173
|
end
|
|
196
174
|
|
|
197
175
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -223,19 +201,11 @@ module Tuile
|
|
|
223
201
|
end
|
|
224
202
|
end
|
|
225
203
|
|
|
226
|
-
# Drops the last `n` hard lines from the buffer
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
# by `append(new_tail)` to replace the damaged region in place.
|
|
232
|
-
#
|
|
233
|
-
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
234
|
-
# `n >= hard-line count` empties the buffer.
|
|
235
|
-
#
|
|
236
|
-
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
237
|
-
# buffer stores), not on wrapped physical rows — same granularity
|
|
238
|
-
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
204
|
+
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
205
|
+
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
206
|
+
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
207
|
+
# (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
|
|
208
|
+
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
239
209
|
# @param n [Integer] number of hard lines to drop; must be >= 0.
|
|
240
210
|
# @raise [TypeError] if `n` isn't an `Integer`.
|
|
241
211
|
# @raise [ArgumentError] if `n` is negative.
|
|
@@ -266,43 +236,23 @@ module Tuile
|
|
|
266
236
|
@top_line = top_line_max if @top_line > top_line_max
|
|
267
237
|
update_top_line_if_auto_scroll
|
|
268
238
|
invalidate
|
|
269
|
-
self.content_size = compute_content_size
|
|
270
239
|
end
|
|
271
240
|
|
|
272
|
-
# Replaces a contiguous range of hard lines with the parsed content
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
# `"\n"` in the replacement produces multiple hard lines, so a single
|
|
278
|
-
# `replace` can grow or shrink the buffer.
|
|
279
|
-
#
|
|
280
|
-
# `range` selects which hard lines to swap out:
|
|
281
|
-
#
|
|
282
|
-
# - an `Integer` `n` is shorthand for `n..n` (replace one existing
|
|
283
|
-
# line — `n` must be in `[0, hard-line count)`);
|
|
284
|
-
# - a non-empty `Range` of hard-line indices replaces those lines;
|
|
285
|
-
# - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
|
|
286
|
-
# `hard_lines.size...hard_lines.size`) is *insertion* at that
|
|
287
|
-
# position — no lines are removed. {#insert} is a thin alias for
|
|
288
|
-
# this case.
|
|
241
|
+
# Replaces a contiguous range of hard lines with the parsed content of
|
|
242
|
+
# `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
|
|
243
|
+
# empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
|
|
244
|
+
# lines, so one `replace` can grow or shrink the buffer. `range` selects
|
|
245
|
+
# which hard lines to swap out:
|
|
289
246
|
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
# Cost is roughly `O(from + length + new content)`: the splice
|
|
296
|
-
# updates only the affected slice of the physical-row buffer, using
|
|
297
|
-
# the per-hard-line wrap-count cache to locate the starting offset
|
|
298
|
-
# without re-wrapping preceding lines. Lines outside the splice are
|
|
299
|
-
# never re-wrapped. {#top_line} is clamped if the new line count
|
|
300
|
-
# puts it past the end; {#auto_scroll} pins it to the bottom as
|
|
301
|
-
# usual. The call is a no-op (no invalidation) when the parsed
|
|
302
|
-
# replacement equals the covered range (vacuously true for an empty
|
|
303
|
-
# range plus empty replacement, so `replace(n...n, "")` is a cheap
|
|
304
|
-
# no-op).
|
|
247
|
+
# - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
|
|
248
|
+
# - a non-empty `Range` replaces those lines;
|
|
249
|
+
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
250
|
+
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
305
251
|
#
|
|
252
|
+
# Splices in place — only the affected slice of the physical-row buffer is
|
|
253
|
+
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
254
|
+
# content)). A no-op when the replacement equals the covered range, so
|
|
255
|
+
# `replace(n...n, "")` is cheap.
|
|
306
256
|
# @param range [Range, Integer] hard-line indices to replace.
|
|
307
257
|
# @param str [String, StyledString, nil] replacement content.
|
|
308
258
|
# @raise [TypeError] if `range` is neither an `Integer` nor a `Range`,
|
|
@@ -327,7 +277,6 @@ module Tuile
|
|
|
327
277
|
@top_line = top_line_max if @top_line > top_line_max
|
|
328
278
|
update_top_line_if_auto_scroll
|
|
329
279
|
invalidate
|
|
330
|
-
self.content_size = compute_content_size
|
|
331
280
|
end
|
|
332
281
|
|
|
333
282
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -386,13 +335,6 @@ module Tuile
|
|
|
386
335
|
|
|
387
336
|
def tab_stop? = true
|
|
388
337
|
|
|
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
338
|
# @param key [String]
|
|
397
339
|
# @return [Boolean]
|
|
398
340
|
def handle_key(key)
|
|
@@ -428,6 +370,8 @@ module Tuile
|
|
|
428
370
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
429
371
|
# painted explicitly (with padded blanks past the last line), so the
|
|
430
372
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
373
|
+
# Rows go through {Component#draw_line}, so content and blank rows inherit
|
|
374
|
+
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
431
375
|
# @return [void]
|
|
432
376
|
def repaint
|
|
433
377
|
return if rect.empty?
|
|
@@ -437,7 +381,7 @@ module Tuile
|
|
|
437
381
|
end
|
|
438
382
|
(0...rect.height).each do |row|
|
|
439
383
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
440
|
-
|
|
384
|
+
draw_line(rect.left, rect.top + row, line)
|
|
441
385
|
end
|
|
442
386
|
end
|
|
443
387
|
|
|
@@ -544,7 +488,6 @@ module Tuile
|
|
|
544
488
|
@top_line = top_line_max if @top_line > top_line_max
|
|
545
489
|
update_top_line_if_auto_scroll
|
|
546
490
|
invalidate
|
|
547
|
-
self.content_size = compute_content_size
|
|
548
491
|
end
|
|
549
492
|
|
|
550
493
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -570,7 +513,6 @@ module Tuile
|
|
|
570
513
|
@top_line = top_line_max if @top_line > top_line_max
|
|
571
514
|
update_top_line_if_auto_scroll
|
|
572
515
|
invalidate
|
|
573
|
-
self.content_size = compute_content_size
|
|
574
516
|
end
|
|
575
517
|
|
|
576
518
|
# Verbatim append into `region`.
|
|
@@ -610,7 +552,6 @@ module Tuile
|
|
|
610
552
|
@top_line = top_line_max if @top_line > top_line_max
|
|
611
553
|
update_top_line_if_auto_scroll
|
|
612
554
|
invalidate
|
|
613
|
-
self.content_size = compute_content_size
|
|
614
555
|
end
|
|
615
556
|
|
|
616
557
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -632,7 +573,6 @@ module Tuile
|
|
|
632
573
|
@top_line = top_line_max if @top_line > top_line_max
|
|
633
574
|
update_top_line_if_auto_scroll
|
|
634
575
|
invalidate
|
|
635
|
-
self.content_size = compute_content_size
|
|
636
576
|
end
|
|
637
577
|
|
|
638
578
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -658,22 +598,14 @@ module Tuile
|
|
|
658
598
|
@top_line = top_line_max if @top_line > top_line_max
|
|
659
599
|
update_top_line_if_auto_scroll
|
|
660
600
|
invalidate
|
|
661
|
-
self.content_size = compute_content_size
|
|
662
601
|
end
|
|
663
602
|
|
|
664
|
-
# Adjusts region line counts after a {@hard_lines} splice that
|
|
665
|
-
#
|
|
666
|
-
#
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
670
|
-
# region that lost lines — that's the natural home for the
|
|
671
|
-
# replacement content.
|
|
672
|
-
# 2. Credit `added_count` to that region. For pure insertions (no
|
|
673
|
-
# removal), there's no "first overlapping region" to pick from;
|
|
674
|
-
# walk regions and credit the latest one starting at `from` (the
|
|
675
|
-
# boundary tiebreaker matches the spatial-tail-routing of
|
|
676
|
-
# {#append}). Past-the-end inserts fall back to the tail region.
|
|
603
|
+
# Adjusts region line counts after a {@hard_lines} splice that removed
|
|
604
|
+
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
605
|
+
# each region's overlap with the removed range, then credits the added
|
|
606
|
+
# lines to the first region that lost lines. Pure insertions have no such
|
|
607
|
+
# region — they credit the latest region starting at `from`, matching
|
|
608
|
+
# {#append}'s spatial-tail routing (past-the-end falls back to the tail).
|
|
677
609
|
# @param from [Integer]
|
|
678
610
|
# @param removed_count [Integer]
|
|
679
611
|
# @param added_count [Integer]
|
|
@@ -832,13 +764,6 @@ module Tuile
|
|
|
832
764
|
StyledString.new(spans)
|
|
833
765
|
end
|
|
834
766
|
|
|
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
767
|
# @return [Integer] column width available for wrapped text — viewport
|
|
843
768
|
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
844
769
|
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
@@ -979,14 +904,9 @@ module Tuile
|
|
|
979
904
|
end
|
|
980
905
|
|
|
981
906
|
# Verbatim append into this region's tail. Same semantics as
|
|
982
|
-
# {TextView#append} but scoped
|
|
983
|
-
#
|
|
984
|
-
#
|
|
985
|
-
# is a no-op (but still raises when detached). When the region is
|
|
986
|
-
# the spatial tail of the view, this uses the incremental
|
|
987
|
-
# {TextView#append} path; mid-document regions splice the affected
|
|
988
|
-
# slice of the physical-row buffer (lines outside the region are
|
|
989
|
-
# not re-wrapped).
|
|
907
|
+
# {TextView#append} but scoped: embedded `"\n"` creates new hard lines
|
|
908
|
+
# within the region, other input extends the region's last hard line.
|
|
909
|
+
# Empty / `nil` is a no-op (but still raises when detached).
|
|
990
910
|
# @param str [String, StyledString, nil]
|
|
991
911
|
# @raise [RuntimeError] when the region is detached.
|
|
992
912
|
# @return [void]
|
|
@@ -13,45 +13,55 @@ module Tuile
|
|
|
13
13
|
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
14
14
|
class Window < Component
|
|
15
15
|
include Component::HasContent
|
|
16
|
+
include Component::HasCaption
|
|
16
17
|
|
|
17
|
-
# @param caption [String]
|
|
18
|
-
|
|
18
|
+
# @param caption [String, StyledString, nil] the border title, coerced
|
|
19
|
+
# the same way {HasCaption#caption=} coerces it.
|
|
20
|
+
def initialize(caption = nil)
|
|
19
21
|
super()
|
|
20
22
|
@border_right = 1
|
|
21
|
-
|
|
23
|
+
self.caption = caption
|
|
22
24
|
@content = nil
|
|
23
|
-
# Optional bottom-row
|
|
24
|
-
#
|
|
25
|
+
# Optional bottom-row widget slot (e.g. a search field), spanning the
|
|
26
|
+
# full inner width; and optional bottom-border chrome text embedded in
|
|
27
|
+
# the border line (mutually exclusive — the component, when present,
|
|
28
|
+
# occupies the row and hides the text).
|
|
25
29
|
@footer = nil
|
|
26
|
-
@
|
|
27
|
-
update_content_size
|
|
30
|
+
@footer_text = StyledString::EMPTY
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def focusable? = true
|
|
31
34
|
|
|
32
|
-
# @return [Component, nil] optional component
|
|
33
|
-
# row.
|
|
35
|
+
# @return [Component, nil] optional focusable component occupying the
|
|
36
|
+
# bottom border row, always spanning the full inner width.
|
|
34
37
|
attr_reader :footer
|
|
35
38
|
|
|
36
|
-
# @return [
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
attr_reader :
|
|
40
|
-
|
|
41
|
-
# Sets the
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
# @return [StyledString] optional chrome embedded into the bottom border
|
|
40
|
+
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
41
|
+
# whenever a {#footer} component is present.
|
|
42
|
+
attr_reader :footer_text
|
|
43
|
+
|
|
44
|
+
# Sets the bottom-border chrome. Accepts a `String` (parsed via
|
|
45
|
+
# {StyledString.parse}), a {StyledString}, or `nil` (clears it). The text
|
|
46
|
+
# embeds into the bottom border at its own width with the border's dashes
|
|
47
|
+
# filling the remainder, clipped to the inner width — border decoration,
|
|
48
|
+
# not a component (not focusable). Hidden whenever a {#footer} component
|
|
49
|
+
# occupies the bottom row (see the precedence note on {#footer=}).
|
|
50
|
+
# @param text [String, StyledString, nil]
|
|
51
|
+
def footer_text=(text)
|
|
52
|
+
new_text = StyledString.parse(text)
|
|
53
|
+
return if @footer_text == new_text
|
|
54
|
+
|
|
55
|
+
@footer_text = new_text
|
|
56
|
+
invalidate # repaint the bottom border line
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sets the bottom-row widget slot. The footer occupies the bottom border
|
|
60
|
+
# row, spanning the full inner width, and is positioned automatically;
|
|
61
|
+
# pass `nil` to remove.
|
|
62
|
+
#
|
|
63
|
+
# Precedence: a footer component present hides {#footer_text}; absent, the
|
|
64
|
+
# text embeds into the bottom border. No window needs both at once.
|
|
55
65
|
#
|
|
56
66
|
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
57
67
|
# pointers, invalidates the old/new components and the window border, and
|
|
@@ -67,10 +77,12 @@ module Tuile
|
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
old = @footer
|
|
70
|
-
|
|
80
|
+
# Same slot-swap order as HasContent#content=: notified last, so the
|
|
81
|
+
# focus repair cascades into the new occupant rather than the old.
|
|
82
|
+
detach_child(old) unless old.nil?
|
|
71
83
|
@footer = new_footer
|
|
72
84
|
unless new_footer.nil?
|
|
73
|
-
new_footer
|
|
85
|
+
add_child(new_footer) # appended: the footer paints over the border row
|
|
74
86
|
new_footer.invalidate
|
|
75
87
|
layout_footer
|
|
76
88
|
end
|
|
@@ -78,11 +90,6 @@ module Tuile
|
|
|
78
90
|
on_child_removed(old) unless old.nil?
|
|
79
91
|
end
|
|
80
92
|
|
|
81
|
-
# @return [Array<Component>]
|
|
82
|
-
def children
|
|
83
|
-
@footer.nil? ? super : super + [@footer]
|
|
84
|
-
end
|
|
85
|
-
|
|
86
93
|
# @param event [MouseEvent]
|
|
87
94
|
# @return [void]
|
|
88
95
|
def handle_mouse(event)
|
|
@@ -114,46 +121,6 @@ module Tuile
|
|
|
114
121
|
layout(content)
|
|
115
122
|
end
|
|
116
123
|
|
|
117
|
-
# @return [String] the current caption, empty by default.
|
|
118
|
-
attr_reader :caption
|
|
119
|
-
|
|
120
|
-
# Sets new caption and repaints the window.
|
|
121
|
-
# @param new_caption [String]
|
|
122
|
-
def caption=(new_caption)
|
|
123
|
-
@caption = new_caption
|
|
124
|
-
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
|
-
end
|
|
156
|
-
|
|
157
124
|
# Fully repaints the window: both frame and contents.
|
|
158
125
|
#
|
|
159
126
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -172,15 +139,6 @@ module Tuile
|
|
|
172
139
|
repaint_border
|
|
173
140
|
end
|
|
174
141
|
|
|
175
|
-
# @param key [String, nil]
|
|
176
|
-
# @return [void]
|
|
177
|
-
def key_shortcut=(key)
|
|
178
|
-
super
|
|
179
|
-
# The shortcut key is shown in the caption — repaint.
|
|
180
|
-
invalidate
|
|
181
|
-
update_content_size
|
|
182
|
-
end
|
|
183
|
-
|
|
184
142
|
protected
|
|
185
143
|
|
|
186
144
|
# @param content [Component]
|
|
@@ -189,9 +147,12 @@ module Tuile
|
|
|
189
147
|
content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
190
148
|
end
|
|
191
149
|
|
|
192
|
-
# Paints the window border
|
|
193
|
-
#
|
|
194
|
-
#
|
|
150
|
+
# Paints the window border via {Component#draw_line}/{Component#draw_char},
|
|
151
|
+
# so the border cells inherit {Component#effective_bg_color} — a
|
|
152
|
+
# {Component#bg_color} on the window tints border and content alike. Both
|
|
153
|
+
# border lines are clipped by *display* width, so no caption overflows the
|
|
154
|
+
# box; when the window is active the whole border — the caption's own
|
|
155
|
+
# colors included — is drawn in {Theme#active_border_color}.
|
|
195
156
|
# @return [void]
|
|
196
157
|
def repaint_border
|
|
197
158
|
return if rect.empty?
|
|
@@ -201,53 +162,59 @@ module Tuile
|
|
|
201
162
|
top = rect.top
|
|
202
163
|
left = rect.left
|
|
203
164
|
inner_w = [w - 2, 0].max
|
|
204
|
-
title = frame_caption.to_s
|
|
205
|
-
title = title[0, inner_w] if title.length > inner_w
|
|
206
|
-
dashes = "─" * (inner_w - title.length)
|
|
207
165
|
|
|
208
166
|
fg = active? ? screen.theme.active_border_color : nil
|
|
209
167
|
bar = StyledString::Style.new(fg: fg)
|
|
210
|
-
|
|
211
|
-
buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
|
|
168
|
+
draw_line(left, top, top_border(inner_w, fg).slice(0, w))
|
|
212
169
|
(1..(h - 2)).each do |dy|
|
|
213
|
-
|
|
214
|
-
|
|
170
|
+
draw_char(left, top + dy, "│", bar)
|
|
171
|
+
draw_char(left + w - 1, top + dy, "│", bar)
|
|
215
172
|
end
|
|
216
|
-
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
173
|
+
draw_line(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds the top border line: corners, {#caption} embedded at its own
|
|
177
|
+
# width, dashes filling the remainder. The caption keeps its own styling
|
|
178
|
+
# unless `fg` is set — an active window's border claims it.
|
|
179
|
+
# @param inner_w [Integer] the border's interior width.
|
|
180
|
+
# @param fg [Color, nil] the active-border color, or nil when inactive.
|
|
181
|
+
# @return [StyledString]
|
|
182
|
+
def top_border(inner_w, fg)
|
|
183
|
+
title = caption.slice(0, inner_w)
|
|
184
|
+
title = title.with_fg(fg) if fg
|
|
185
|
+
dashes = StyledString.styled("─" * (inner_w - title.display_width), fg: fg)
|
|
186
|
+
StyledString.styled("┌", fg: fg) + title + dashes + StyledString.styled("┐", fg: fg)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Builds the bottom border line. The corners take the border color; the
|
|
190
|
+
# interior is plain dashes when a {#footer} component occupies the row
|
|
191
|
+
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
192
|
+
# {#footer_text} embedded at its own width — keeping the text's own
|
|
193
|
+
# styling — with dashes filling the remainder up to the inner width.
|
|
194
|
+
# @param inner_w [Integer] the border's interior width.
|
|
195
|
+
# @param fg [Color, nil] the active-border color, or nil when inactive.
|
|
196
|
+
# @return [StyledString]
|
|
197
|
+
def bottom_border(inner_w, fg)
|
|
198
|
+
interior =
|
|
199
|
+
if @footer || @footer_text.empty?
|
|
200
|
+
StyledString.styled("─" * inner_w, fg: fg)
|
|
201
|
+
else
|
|
202
|
+
embedded = @footer_text.slice(0, inner_w)
|
|
203
|
+
embedded + StyledString.styled("─" * (inner_w - embedded.display_width), fg: fg)
|
|
204
|
+
end
|
|
205
|
+
StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
|
|
225
206
|
end
|
|
226
207
|
|
|
227
208
|
private
|
|
228
209
|
|
|
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.
|
|
210
|
+
# Positions the footer over the bottom border row, spanning the full
|
|
211
|
+
# inner width (the only dimension a bottom-row widget needs — the window
|
|
212
|
+
# already knows it).
|
|
245
213
|
# @return [void]
|
|
246
214
|
def layout_footer
|
|
247
215
|
return if @footer.nil? || rect.empty?
|
|
248
216
|
|
|
249
|
-
|
|
250
|
-
width = @footer_sizing.resolve(available, @footer.content_size.width)
|
|
217
|
+
width = [rect.width - 2, 0].max
|
|
251
218
|
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
252
219
|
end
|
|
253
220
|
end
|