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
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -23,7 +23,9 @@ module Tuile
|
|
|
23
23
|
# cascaded to the first focusable child.
|
|
24
24
|
@popup_prior_focus = {}
|
|
25
25
|
@status_bar = Component::Label.new
|
|
26
|
-
|
|
26
|
+
# Added first and never removed, so it is always the last child — which is
|
|
27
|
+
# the anchor `add_popup` inserts against.
|
|
28
|
+
add_child(@status_bar)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# @return [Component, nil] the tiled content component.
|
|
@@ -37,10 +39,6 @@ module Tuile
|
|
|
37
39
|
|
|
38
40
|
def focusable? = false
|
|
39
41
|
|
|
40
|
-
# Children for tree traversal: content first, popups in stacking order,
|
|
41
|
-
# status bar last.
|
|
42
|
-
def children = [*[@content].compact, *@popups, @status_bar]
|
|
43
|
-
|
|
44
42
|
# Replaces the tiled content. Wipes focus first (the new tree starts
|
|
45
43
|
# fresh), detaches the old content, then attaches the new one and
|
|
46
44
|
# re-lays out.
|
|
@@ -51,10 +49,9 @@ module Tuile
|
|
|
51
49
|
return if @content == content
|
|
52
50
|
|
|
53
51
|
screen.focused = nil
|
|
54
|
-
|
|
55
|
-
old&.parent = nil
|
|
52
|
+
remove_child(@content) unless @content.nil?
|
|
56
53
|
@content = content
|
|
57
|
-
content
|
|
54
|
+
add_child(content, at: 0) # the tiled layer paints beneath everything else
|
|
58
55
|
layout
|
|
59
56
|
end
|
|
60
57
|
|
|
@@ -63,6 +60,12 @@ module Tuile
|
|
|
63
60
|
# left wherever the caller positions it and does *not* take focus, so the
|
|
64
61
|
# component that was focused keeps the cursor and keeps receiving keys —
|
|
65
62
|
# the overlay floats above the content, driven from app code.
|
|
63
|
+
#
|
|
64
|
+
# The *whole subtree* is invalidated, not just the popup wrapper (which
|
|
65
|
+
# paints nothing on its own): a reopened popup may land on cells that the
|
|
66
|
+
# tiled content has since overpainted, and if its rect is unchanged from
|
|
67
|
+
# last time its content components won't re-invalidate themselves — so
|
|
68
|
+
# without this the popup's contents would stay blank on reopen.
|
|
66
69
|
# @param window [Component::Popup]
|
|
67
70
|
# @return [void]
|
|
68
71
|
def add_popup(window)
|
|
@@ -71,12 +74,12 @@ module Tuile
|
|
|
71
74
|
|
|
72
75
|
@popup_prior_focus[window] = screen.focused
|
|
73
76
|
@popups << window
|
|
74
|
-
window
|
|
77
|
+
add_child(window, at: @children.index(@status_bar))
|
|
75
78
|
if window.modal?
|
|
76
79
|
window.center
|
|
77
80
|
screen.focused = window
|
|
78
81
|
end
|
|
79
|
-
screen.invalidate(
|
|
82
|
+
window.on_tree { |c| screen.invalidate(c) }
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# Removes a popup. If the popup held focus, focus shifts to the now-topmost
|
|
@@ -88,21 +91,35 @@ module Tuile
|
|
|
88
91
|
raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
|
|
89
92
|
|
|
90
93
|
prior = @popup_prior_focus.delete(window)
|
|
91
|
-
# Detach first so the popup becomes its own root; then any prior
|
|
92
|
-
# pointing *inside* that popup is detectable via `p.root == window`.
|
|
93
|
-
window.parent = nil
|
|
94
|
-
# If any other popup recorded its prior focus inside the popup we're
|
|
95
|
-
# removing, forward it to *our* prior so chained closures still climb
|
|
96
|
-
# back to the original owner instead of stopping at a detached
|
|
97
|
-
# component.
|
|
98
|
-
@popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
|
|
99
|
-
|
|
100
94
|
@removing_popup_prior = prior
|
|
101
|
-
|
|
95
|
+
remove_child(window)
|
|
96
|
+
# Runs after the detach, so a prior pointing *inside* the removed popup is
|
|
97
|
+
# detectable via `p.root == window`: forward it to *our* prior, so chained
|
|
98
|
+
# closures climb back to the original owner instead of stopping at a
|
|
99
|
+
# detached component.
|
|
100
|
+
@popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
|
|
102
101
|
ensure
|
|
103
102
|
@removing_popup_prior = nil
|
|
104
103
|
end
|
|
105
104
|
|
|
105
|
+
# Unmounts everything: each child is detached — firing {Component#on_detached}
|
|
106
|
+
# down its subtree — and every slot is emptied. Terminal; the pane isn't
|
|
107
|
+
# reusable afterwards, and {Screen#close} is its only caller.
|
|
108
|
+
#
|
|
109
|
+
# Deliberately not named `close` ({Component::Popup#close} already means
|
|
110
|
+
# "remove *me* from the pane"), and deliberately not a generic
|
|
111
|
+
# `Component#remove_all_children`: a slot container calling that would empty
|
|
112
|
+
# `@children` while `#content` / `#footer` still pointed at detached
|
|
113
|
+
# components, which is the desync the tree API exists to prevent.
|
|
114
|
+
# @return [void]
|
|
115
|
+
def detach_all
|
|
116
|
+
screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
|
|
117
|
+
children.dup.each { detach_child(_1) }
|
|
118
|
+
@content = nil
|
|
119
|
+
@popups.clear
|
|
120
|
+
@popup_prior_focus.clear
|
|
121
|
+
end
|
|
122
|
+
|
|
106
123
|
# @param window [Component]
|
|
107
124
|
# @return [Boolean] true if this pane currently hosts the popup.
|
|
108
125
|
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
@@ -123,14 +140,16 @@ module Tuile
|
|
|
123
140
|
end
|
|
124
141
|
|
|
125
142
|
# Lays out content (full pane minus the bottom row) and the status bar
|
|
126
|
-
# (bottom row).
|
|
127
|
-
#
|
|
143
|
+
# (bottom row). Each popup re-resolves its {Component::Popup#size} against
|
|
144
|
+
# the new screen via {Component::Popup#reposition} — so a {Fraction} size
|
|
145
|
+
# tracks resize — repositioning itself (modal popups recenter; non-modal
|
|
146
|
+
# overlays keep the top-left their owner assigned).
|
|
128
147
|
# @return [void]
|
|
129
148
|
def layout
|
|
130
149
|
return if rect.empty?
|
|
131
150
|
|
|
132
151
|
@content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
|
|
133
|
-
@popups.each
|
|
152
|
+
@popups.each(&:reposition)
|
|
134
153
|
@status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
135
154
|
end
|
|
136
155
|
|
|
@@ -138,34 +157,27 @@ module Tuile
|
|
|
138
157
|
# @return [void]
|
|
139
158
|
def repaint; end
|
|
140
159
|
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
160
|
+
# Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
|
|
161
|
+
# the first component whose `handle_key` returns true wins.
|
|
162
|
+
#
|
|
163
|
+
# Bubbling stops at the *scope* root: the topmost *modal* popup when one is
|
|
164
|
+
# open, else the tiled {#content}. Focus that is nil or sits outside the
|
|
165
|
+
# scope receives nothing, which is what keeps an open modal popup modal.
|
|
166
|
+
# Non-modal overlays are never the scope: focus stays in the content
|
|
167
|
+
# beneath them, and the overlay is driven by app code (which forwards keys
|
|
168
|
+
# to it explicitly), so it doesn't appear in this path at all.
|
|
146
169
|
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
# ancestor chain to the scope root; the first component to return true
|
|
153
|
-
# wins. Focus that is nil or sits outside the scope receives nothing,
|
|
154
|
-
# which is what keeps an open modal popup modal.
|
|
170
|
+
# Because an ancestor sees a key only after every descendant on the chain
|
|
171
|
+
# declined it, the scope root is the natural home for scope-wide fallbacks
|
|
172
|
+
# — a form's default button, or a layout's one-key jumps to its panes (a
|
|
173
|
+
# focused {Component::TextField} consumes the key first, so typing is never
|
|
174
|
+
# hijacked).
|
|
155
175
|
# @param key [String]
|
|
156
176
|
# @return [Boolean] true if the key was handled.
|
|
157
177
|
def handle_key(key)
|
|
158
178
|
scope = modal_popup || @content
|
|
159
179
|
return false if scope.nil?
|
|
160
180
|
|
|
161
|
-
if screen.cursor_position.nil?
|
|
162
|
-
target = scope.find_shortcut_component(key)
|
|
163
|
-
unless target.nil?
|
|
164
|
-
screen.focused = target
|
|
165
|
-
return true
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
181
|
bubble_key(key, scope)
|
|
170
182
|
end
|
|
171
183
|
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -3,14 +3,11 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
# An immutable string-with-styling, modeled as a sequence of {Span}s where
|
|
5
5
|
# each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
|
|
6
|
-
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
7
|
-
# character has exactly one resolved style, no overlay
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# they never have to "figure out what SGR state is active at column N" —
|
|
12
|
-
# the answer is just the containing span's `style`. The flip side is one
|
|
13
|
-
# extra type to construct (or parse) before doing styled-text math.
|
|
6
|
+
# `underline`, `strikethrough`). Spans are non-overlapping and fully tile
|
|
7
|
+
# the string — every character has exactly one resolved style, no overlay
|
|
8
|
+
# layers to merge, so the style at any column is just its span's `style`
|
|
9
|
+
# rather than a replay of the SGR state machine. The book's chapter 9 is
|
|
10
|
+
# the long-form *why* (spans vs. a `String` full of escape codes).
|
|
14
11
|
#
|
|
15
12
|
# ## Constructors
|
|
16
13
|
#
|
|
@@ -34,30 +31,15 @@ module Tuile
|
|
|
34
31
|
# ss.each_char_with_style { |ch, style| ... }
|
|
35
32
|
# ```
|
|
36
33
|
#
|
|
37
|
-
# ## Rendering
|
|
38
|
-
#
|
|
39
|
-
# - `#to_s` — plain text, no SGR.
|
|
40
|
-
# - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
|
|
41
|
-
# the last span carried a non-default style. Transitions to the default
|
|
42
|
-
# style emit `\e[0m` (shorter than re-emitting every off-code).
|
|
43
|
-
#
|
|
44
34
|
# ## Parser
|
|
45
35
|
#
|
|
46
|
-
# {.parse} is strict by default
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# Pass `lenient: true` to instead **discard** everything the parser can't
|
|
54
|
-
# model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
|
|
55
|
-
# apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
|
|
56
|
-
# (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
|
|
57
|
-
# silently dropped. This is the mode for piping in colored output you don't
|
|
58
|
-
# control (e.g. `git --color` through a pager): "give me the colors, throw
|
|
59
|
-
# the rest away." It is lossy by design — `parse(x, lenient: true)` does not
|
|
60
|
-
# round-trip back to `x`.
|
|
36
|
+
# {.parse} is strict by default — it recognizes only the SGR codes for
|
|
37
|
+
# {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
|
|
38
|
+
# raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
|
|
39
|
+
# round-trip honest. Pass `lenient: true` to instead discard everything it
|
|
40
|
+
# can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
|
|
41
|
+
# only the recognized colors — lossy by design, for piping in colored output
|
|
42
|
+
# you don't control. See the book for the full rationale.
|
|
61
43
|
class StyledString
|
|
62
44
|
# Raised by {.parse} on malformed or unsupported escape sequences.
|
|
63
45
|
class ParseError < Error; end
|
|
@@ -111,10 +93,6 @@ module Tuile
|
|
|
111
93
|
# `""` when the styles are identical (nothing to do), and {Ansi::RESET}
|
|
112
94
|
# (`\e[0m`, one code) when `other` is the default style — shorter than
|
|
113
95
|
# turning each attribute off individually.
|
|
114
|
-
#
|
|
115
|
-
# Shared by {StyledString#to_ansi} (diffing span-to-span from the default
|
|
116
|
-
# style) and {Buffer}'s flush (diffing cell-to-cell against the style the
|
|
117
|
-
# terminal currently holds), so both emit identical minimal sequences.
|
|
118
96
|
# @param other [Style] the style to transition to.
|
|
119
97
|
# @return [String]
|
|
120
98
|
def sgr_to(other)
|
|
@@ -419,6 +397,19 @@ module Tuile
|
|
|
419
397
|
end
|
|
420
398
|
end
|
|
421
399
|
|
|
400
|
+
# The framework's single emoji-width policy, passed to every
|
|
401
|
+
# `Unicode::DisplayWidth.of` call in Tuile.
|
|
402
|
+
#
|
|
403
|
+
# `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
|
|
404
|
+
# sequences — the ones vendors actually ship a single glyph for — and *sums*
|
|
405
|
+
# the parts of anything else. That is the one setting never wrong in the
|
|
406
|
+
# dangerous direction: under-measuring lets a glyph overrun its cell, which
|
|
407
|
+
# shifts the rest of the row, desyncs the cursor and escapes the component's
|
|
408
|
+
# rect, while over-measuring leaves a blank column. `D-cluster-width` has the
|
|
409
|
+
# per-setting reasoning.
|
|
410
|
+
# @return [Symbol]
|
|
411
|
+
EMOJI_WIDTH = :rgi
|
|
412
|
+
|
|
422
413
|
# @return [Array<Span>] the frozen, normalized span list — no empty-text
|
|
423
414
|
# entries, no two adjacent entries sharing a style.
|
|
424
415
|
attr_reader :spans
|
|
@@ -430,10 +421,9 @@ module Tuile
|
|
|
430
421
|
|
|
431
422
|
# Total display width in terminal columns, accounting for Unicode wide
|
|
432
423
|
# characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
|
|
433
|
-
# Memoized — safe because spans are frozen and immutable.
|
|
434
424
|
# @return [Integer]
|
|
435
425
|
def display_width
|
|
436
|
-
@display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
|
|
426
|
+
@display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text, emoji: EMOJI_WIDTH) }
|
|
437
427
|
end
|
|
438
428
|
|
|
439
429
|
# @return [Boolean]
|
|
@@ -450,7 +440,7 @@ module Tuile
|
|
|
450
440
|
# emits `\e[0m` (one code) instead of the longer "turn each attribute
|
|
451
441
|
# off" form. Always closes with `\e[0m` when the last span carried a
|
|
452
442
|
# non-default style, so the styled run doesn't bleed into subsequent
|
|
453
|
-
# output.
|
|
443
|
+
# output.
|
|
454
444
|
# @return [String]
|
|
455
445
|
def to_ansi
|
|
456
446
|
@to_ansi ||= build_ansi
|
|
@@ -600,6 +590,24 @@ module Tuile
|
|
|
600
590
|
self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
|
|
601
591
|
end
|
|
602
592
|
|
|
593
|
+
# Returns a copy with `bg` set **only on spans that have none**; a span with
|
|
594
|
+
# an explicit bg is left untouched. The fill-unset counterpart of {#with_bg}
|
|
595
|
+
# (which overrides every span) — it slides a background *under* the content,
|
|
596
|
+
# so a log line keeps its red error-level bg while its plain text picks up an
|
|
597
|
+
# inherited panel tint.
|
|
598
|
+
#
|
|
599
|
+
# @param bg [Color, Symbol, Integer, Array<Integer>, nil] background color,
|
|
600
|
+
# coerced via {Color.coerce}. `nil` returns `self` unchanged.
|
|
601
|
+
# @return [StyledString]
|
|
602
|
+
def under_bg(bg)
|
|
603
|
+
return self if bg.nil?
|
|
604
|
+
|
|
605
|
+
bg = Color.coerce(bg)
|
|
606
|
+
self.class.new(@spans.map do |span|
|
|
607
|
+
span.style.bg.nil? ? Span.new(text: span.text, style: span.style.merge(bg: bg)) : span
|
|
608
|
+
end)
|
|
609
|
+
end
|
|
610
|
+
|
|
603
611
|
# Returns a new {StyledString} with `fg` applied to every span, preserving
|
|
604
612
|
# each span's text and other style attributes (`bg`, `bold`, `italic`,
|
|
605
613
|
# `underline`, `strikethrough`). The new fg overlays without dropping background colors or
|
|
@@ -683,7 +691,7 @@ module Tuile
|
|
|
683
691
|
out = []
|
|
684
692
|
col = 0
|
|
685
693
|
@spans.each do |span|
|
|
686
|
-
span_width = Unicode::DisplayWidth.of(span.text)
|
|
694
|
+
span_width = Unicode::DisplayWidth.of(span.text, emoji: EMOJI_WIDTH)
|
|
687
695
|
span_end = col + span_width
|
|
688
696
|
|
|
689
697
|
next col = span_end if span_end <= start
|
|
@@ -707,74 +715,92 @@ module Tuile
|
|
|
707
715
|
return [hard_line] if hard_line.empty?
|
|
708
716
|
|
|
709
717
|
result = []
|
|
710
|
-
|
|
718
|
+
line_glyphs = []
|
|
711
719
|
line_w = 0
|
|
712
720
|
|
|
713
|
-
tokenize_for_wrap(hard_line).each do |type,
|
|
721
|
+
tokenize_for_wrap(hard_line).each do |type, glyphs, w|
|
|
714
722
|
if type == :space
|
|
715
723
|
if line_w.zero?
|
|
716
724
|
# leading whitespace on a wrapped continuation: drop
|
|
717
725
|
elsif line_w + w <= width
|
|
718
|
-
|
|
726
|
+
line_glyphs.concat(glyphs)
|
|
719
727
|
line_w += w
|
|
720
728
|
else
|
|
721
|
-
result <<
|
|
722
|
-
|
|
729
|
+
result << glyphs_to_styled(line_glyphs)
|
|
730
|
+
line_glyphs = []
|
|
723
731
|
line_w = 0
|
|
724
732
|
end
|
|
725
733
|
elsif line_w + w <= width
|
|
726
|
-
|
|
734
|
+
line_glyphs.concat(glyphs)
|
|
727
735
|
line_w += w
|
|
728
736
|
elsif w > width
|
|
729
|
-
result <<
|
|
730
|
-
chunks =
|
|
731
|
-
chunks[0..-2].each { |chunk| result <<
|
|
732
|
-
|
|
733
|
-
line_w =
|
|
737
|
+
result << glyphs_to_styled(line_glyphs) unless line_w.zero?
|
|
738
|
+
chunks = hard_break_glyphs(glyphs, width)
|
|
739
|
+
chunks[0..-2].each { |chunk| result << glyphs_to_styled(chunk) }
|
|
740
|
+
line_glyphs = chunks.last
|
|
741
|
+
line_w = line_glyphs.sum { |triple| triple[2] }
|
|
734
742
|
else
|
|
735
|
-
result <<
|
|
736
|
-
|
|
743
|
+
result << glyphs_to_styled(line_glyphs)
|
|
744
|
+
line_glyphs = glyphs
|
|
737
745
|
line_w = w
|
|
738
746
|
end
|
|
739
747
|
end
|
|
740
|
-
result <<
|
|
748
|
+
result << glyphs_to_styled(line_glyphs)
|
|
741
749
|
result
|
|
742
750
|
end
|
|
743
751
|
|
|
752
|
+
# Splits into whitespace/word tokens by **grapheme cluster**, not character:
|
|
753
|
+
# a cluster is the unit a terminal draws, so measuring its parts separately
|
|
754
|
+
# would both mis-total an emoji sequence and let a wrap break a letter away
|
|
755
|
+
# from its combining mark.
|
|
744
756
|
# @param hard_line [StyledString]
|
|
745
|
-
# @return [Array<Array>] tokens shaped `[type,
|
|
746
|
-
# `:space` or `:word`, `
|
|
747
|
-
# (
|
|
757
|
+
# @return [Array<Array>] tokens shaped `[type, glyphs, w]` where `type` is
|
|
758
|
+
# `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
|
|
759
|
+
# (grapheme cluster, style, display width), and `w` is the token's total
|
|
760
|
+
# width.
|
|
748
761
|
def tokenize_for_wrap(hard_line)
|
|
749
762
|
tokens = []
|
|
750
|
-
|
|
763
|
+
current_glyphs = []
|
|
751
764
|
current_w = 0
|
|
752
765
|
current_type = nil
|
|
753
766
|
|
|
754
|
-
hard_line
|
|
755
|
-
type = [" ", "\t"].include?(
|
|
756
|
-
|
|
767
|
+
each_glyph_with_style(hard_line) do |g, s|
|
|
768
|
+
type = [" ", "\t"].include?(g) ? :space : :word
|
|
769
|
+
gw = Buffer.display_width(g)
|
|
757
770
|
if current_type && current_type != type
|
|
758
|
-
tokens << [current_type,
|
|
759
|
-
|
|
771
|
+
tokens << [current_type, current_glyphs, current_w]
|
|
772
|
+
current_glyphs = []
|
|
760
773
|
current_w = 0
|
|
761
774
|
end
|
|
762
775
|
current_type = type
|
|
763
|
-
|
|
764
|
-
current_w +=
|
|
776
|
+
current_glyphs << [g, s, gw]
|
|
777
|
+
current_w += gw
|
|
765
778
|
end
|
|
766
|
-
tokens << [current_type,
|
|
779
|
+
tokens << [current_type, current_glyphs, current_w] unless current_glyphs.empty?
|
|
767
780
|
tokens
|
|
768
781
|
end
|
|
769
782
|
|
|
770
|
-
#
|
|
783
|
+
# Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
|
|
784
|
+
# style boundary takes the style of its first span — pathological input, and
|
|
785
|
+
# splitting the cluster to honor both styles would paint a headless mark.
|
|
786
|
+
# @param styled [StyledString]
|
|
787
|
+
# @yieldparam glyph [String] one grapheme cluster.
|
|
788
|
+
# @yieldparam style [Style]
|
|
789
|
+
# @return [void]
|
|
790
|
+
def each_glyph_with_style(styled)
|
|
791
|
+
styled.spans.each do |span|
|
|
792
|
+
span.text.each_grapheme_cluster { |g| yield g, span.style }
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
|
|
771
797
|
# @param width [Integer]
|
|
772
|
-
# @return [Array<Array<Array>>] each inner Array is a `
|
|
773
|
-
def
|
|
798
|
+
# @return [Array<Array<Array>>] each inner Array is a `glyphs`-shaped chunk.
|
|
799
|
+
def hard_break_glyphs(glyphs, width)
|
|
774
800
|
chunks = []
|
|
775
801
|
current = []
|
|
776
802
|
current_w = 0
|
|
777
|
-
|
|
803
|
+
glyphs.each do |triple|
|
|
778
804
|
cw = triple[2]
|
|
779
805
|
if current_w + cw > width && current_w.positive?
|
|
780
806
|
chunks << current
|
|
@@ -788,20 +814,20 @@ module Tuile
|
|
|
788
814
|
chunks
|
|
789
815
|
end
|
|
790
816
|
|
|
791
|
-
# @param
|
|
817
|
+
# @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
|
|
792
818
|
# @return [StyledString]
|
|
793
|
-
def
|
|
794
|
-
return self.class.new if
|
|
819
|
+
def glyphs_to_styled(glyphs)
|
|
820
|
+
return self.class.new if glyphs.empty?
|
|
795
821
|
|
|
796
822
|
spans = []
|
|
797
823
|
current_text = +""
|
|
798
|
-
current_style =
|
|
799
|
-
|
|
824
|
+
current_style = glyphs.first[1]
|
|
825
|
+
glyphs.each do |g, s, _|
|
|
800
826
|
if s == current_style
|
|
801
|
-
current_text <<
|
|
827
|
+
current_text << g
|
|
802
828
|
else
|
|
803
829
|
spans << Span.new(text: current_text, style: current_style)
|
|
804
|
-
current_text = +
|
|
830
|
+
current_text = +g
|
|
805
831
|
current_style = s
|
|
806
832
|
end
|
|
807
833
|
end
|
|
@@ -809,6 +835,9 @@ module Tuile
|
|
|
809
835
|
self.class.new(spans)
|
|
810
836
|
end
|
|
811
837
|
|
|
838
|
+
# Walks **grapheme clusters**, so a slice boundary can never fall inside one:
|
|
839
|
+
# cutting a cluster would strand a combining mark with no base, which the
|
|
840
|
+
# painter drops outright, silently losing the accent off a letter.
|
|
812
841
|
# @param text [String]
|
|
813
842
|
# @param start_col [Integer]
|
|
814
843
|
# @param len_col [Integer]
|
|
@@ -816,18 +845,18 @@ module Tuile
|
|
|
816
845
|
def slice_text_by_columns(text, start_col, len_col)
|
|
817
846
|
out = +""
|
|
818
847
|
col = 0
|
|
819
|
-
text.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if
|
|
848
|
+
text.each_grapheme_cluster do |g|
|
|
849
|
+
gw = Buffer.display_width(g)
|
|
850
|
+
glyph_end = col + gw
|
|
851
|
+
if glyph_end <= start_col
|
|
823
852
|
# entirely before slice — skip
|
|
824
853
|
elsif col >= start_col + len_col
|
|
825
854
|
break
|
|
826
|
-
elsif col >= start_col &&
|
|
827
|
-
out <<
|
|
855
|
+
elsif col >= start_col && glyph_end <= start_col + len_col
|
|
856
|
+
out << g
|
|
828
857
|
end
|
|
829
|
-
# any other case = partial overlap with a wide
|
|
830
|
-
col =
|
|
858
|
+
# any other case = partial overlap with a wide glyph — drop
|
|
859
|
+
col = glyph_end
|
|
831
860
|
end
|
|
832
861
|
out
|
|
833
862
|
end
|
data/lib/tuile/theme.rb
CHANGED
|
@@ -1,67 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
|
-
# A set of semantic colors the built-in components read when painting.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# A set of semantic colors the built-in components read when painting. The
|
|
5
|
+
# current theme lives at {Screen#theme}; components must look it up at paint
|
|
6
|
+
# time (inside `repaint`) rather than caching values, so a {Screen#theme=}
|
|
7
|
+
# restyles everything via one invalidate-everything pass. Book ch6 is the
|
|
8
|
+
# concept in full (why accents-only, dark/light, live OS flips).
|
|
9
9
|
#
|
|
10
|
-
# The
|
|
11
|
-
# {#
|
|
12
|
-
#
|
|
13
|
-
# role) and reset:
|
|
10
|
+
# The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
|
|
11
|
+
# {#hint} — wrap a plain string in the token's SGR color (on the channel
|
|
12
|
+
# appropriate for the token's role) and reset:
|
|
14
13
|
#
|
|
15
14
|
# screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
16
15
|
# screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
17
16
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
|
|
24
|
-
# helper; structured text → `*_color` reader + {StyledString}.
|
|
17
|
+
# Content passes through verbatim (so it may carry other escapes). For
|
|
18
|
+
# span-aware styling — a token applied to a {StyledString} without flattening
|
|
19
|
+
# its per-span colors — use the `*_color` readers instead
|
|
20
|
+
# (`with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome → helper;
|
|
21
|
+
# structured text → `*_color` reader + {StyledString}.
|
|
25
22
|
#
|
|
26
|
-
# Two built-in themes
|
|
27
|
-
#
|
|
28
|
-
#
|
|
23
|
+
# Two built-in themes ship: {DARK} (default) and {LIGHT}. A custom one is one
|
|
24
|
+
# `with` away, and every token must be a {Color} instance — not the lenient
|
|
25
|
+
# {Color.coerce} forms, since a theme is declared once so the verbosity
|
|
26
|
+
# self-documents:
|
|
29
27
|
#
|
|
30
28
|
# screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
|
|
31
29
|
#
|
|
32
|
-
# Tokens deliberately cover only the *accents* Tuile paints. Everything
|
|
33
|
-
# else inherits the terminal's own default foreground/background, which
|
|
34
|
-
# already matches the user's terminal theme perfectly — that's why there
|
|
35
|
-
# is no global `bg`/`fg` token.
|
|
36
|
-
#
|
|
37
|
-
# Every token is a {Color} — and must be passed as one. Unlike the
|
|
38
|
-
# lenient {Color.coerce} call sites elsewhere in the framework, a theme
|
|
39
|
-
# is declared once per app, so it takes only {Color} instances: at a
|
|
40
|
-
# declaration site `Color.palette(130)` documents itself in a way the
|
|
41
|
-
# bare `130` does not (palette index? RGB channel?) — and the named
|
|
42
|
-
# palette constants (`Color::DARK_ORANGE3` *is* 130; see
|
|
43
|
-
# {Color::PALETTE_NAMES}) go one step further.
|
|
44
|
-
#
|
|
45
30
|
# ## App-specific tokens
|
|
46
31
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# {#
|
|
50
|
-
#
|
|
32
|
+
# An app carries its own colors in {#custom} (frozen `Hash{Symbol => Color}`).
|
|
33
|
+
# Look them up with {#[]} (fail-fast on typos) and render with the generic
|
|
34
|
+
# {#fg} / {#bg} helpers; subclass for semantic readers (`Data#with` keeps the
|
|
35
|
+
# subclass). Pair dark/light variants in a {ThemeDef} for {Screen#theme_def=}.
|
|
51
36
|
#
|
|
52
37
|
# theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
|
|
53
38
|
# theme[:accent] # => Color, e.g. for StyledString#with_fg
|
|
54
39
|
# theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
55
40
|
#
|
|
56
|
-
# Apps wanting semantic readers can subclass — `Data#with` preserves the
|
|
57
|
-
# subclass, so an `AppTheme` stays an `AppTheme` through `with`:
|
|
58
|
-
#
|
|
59
41
|
# class AppTheme < Tuile::Theme
|
|
60
42
|
# def accent(text) = fg(:accent, text)
|
|
61
43
|
# end
|
|
62
44
|
#
|
|
63
|
-
#
|
|
64
|
-
# {
|
|
45
|
+
# For a color slot resolved *live* at paint — currently
|
|
46
|
+
# {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
|
|
47
|
+
# the token in {Component#on_theme_changed}; it tracks theme swaps on its
|
|
48
|
+
# own. Baked content colors ({Component::Label} text and friends) can't:
|
|
49
|
+
# they live in a frozen {StyledString} and still need the hook.
|
|
65
50
|
#
|
|
66
51
|
# @!attribute [r] active_bg_color
|
|
67
52
|
# Background highlight of the component the user is interacting with:
|
|
@@ -117,6 +102,58 @@ module Tuile
|
|
|
117
102
|
# loudly, not paint in a default.
|
|
118
103
|
def [](token) = custom.fetch(token)
|
|
119
104
|
|
|
105
|
+
# The built-in chrome color tokens — every {Data} member bar {#custom}. A
|
|
106
|
+
# {Ref} resolves a name in this set as the chrome color; anything else as a
|
|
107
|
+
# {#custom} token.
|
|
108
|
+
# @return [Array<Symbol>]
|
|
109
|
+
CHROME_TOKENS = (members - %i[custom]).freeze
|
|
110
|
+
|
|
111
|
+
# @param name [Symbol] a token name.
|
|
112
|
+
# @return [Boolean] true iff `name` is a built-in chrome token (see
|
|
113
|
+
# {CHROME_TOKENS}) rather than a {#custom} one.
|
|
114
|
+
def self.chrome_token?(name) = CHROME_TOKENS.include?(name)
|
|
115
|
+
|
|
116
|
+
# Builds a {Ref} — a live theme reference for a late-resolved color slot
|
|
117
|
+
# like {Component#bg_color=}. Sugar for `Theme::Ref.new(name)`.
|
|
118
|
+
# @param name [Symbol] a built-in chrome token ({#input_bg_color} etc.) or
|
|
119
|
+
# a {#custom} token name.
|
|
120
|
+
# @return [Ref]
|
|
121
|
+
def self.ref(name) = Ref.new(name)
|
|
122
|
+
|
|
123
|
+
# A live reference to a theme token, resolved against the current theme at
|
|
124
|
+
# paint time rather than baked to a concrete {Color}. Assign one where a
|
|
125
|
+
# slot is resolved late — currently {Component#bg_color=} — and it follows
|
|
126
|
+
# light/dark flips with no {Component#on_theme_changed} hook:
|
|
127
|
+
#
|
|
128
|
+
# panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
|
|
129
|
+
# dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
|
|
130
|
+
#
|
|
131
|
+
# The name may be a built-in chrome token ({CHROME_TOKENS}) or a {#custom}
|
|
132
|
+
# one; a chrome name takes precedence on the (pathological) collision. This
|
|
133
|
+
# does *not* add a global bg/fg token — it only lets a slot point at a
|
|
134
|
+
# color the theme *already* carries, resolved the same way framework chrome
|
|
135
|
+
# already resolves it.
|
|
136
|
+
#
|
|
137
|
+
# Distinct from {Color.coerce}'s symbol support, which names one of the 16
|
|
138
|
+
# ANSI colors and yields a fixed {Color}; a Ref names a *theme* token and
|
|
139
|
+
# re-reads it each paint.
|
|
140
|
+
#
|
|
141
|
+
# Immutable.
|
|
142
|
+
class Ref < Data.define(:name)
|
|
143
|
+
# Resolves to the concrete {Color} `name` maps to in `theme` — a built-in
|
|
144
|
+
# chrome reader when `name` is one ({Theme.chrome_token?}), else a
|
|
145
|
+
# {#custom} token.
|
|
146
|
+
# @param theme [Theme]
|
|
147
|
+
# @return [Color]
|
|
148
|
+
# @raise [KeyError] when `name` is neither a chrome token nor a {#custom}
|
|
149
|
+
# token in `theme`.
|
|
150
|
+
def resolve(theme)
|
|
151
|
+
return theme.public_send(name) if Theme.chrome_token?(name)
|
|
152
|
+
|
|
153
|
+
theme[name]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
120
157
|
# Renders `text` in the foreground color of the app-specific `token`
|
|
121
158
|
# — the generic counterpart of {#hint} for {#custom} tokens.
|
|
122
159
|
# @param token [Symbol]
|