tuile 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +249 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +44 -16
- data/lib/tuile/component/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +696 -58
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +130 -11
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +98 -4
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/sig/tuile.rbs +1160 -93
- metadata +6 -15
data/lib/tuile/theme.rb
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A set of semantic colors the built-in components read when painting.
|
|
5
|
+
# The current theme lives at {Screen#theme}; components must look it up
|
|
6
|
+
# at paint time (inside `repaint`) rather than caching values, so that
|
|
7
|
+
# assigning {Screen#theme=} restyles everything via a single
|
|
8
|
+
# invalidate-everything pass.
|
|
9
|
+
#
|
|
10
|
+
# The primary API is the rendering helpers — {#active_bg},
|
|
11
|
+
# {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
|
|
12
|
+
# the token's SGR color (on the channel appropriate for the token's
|
|
13
|
+
# role) and reset:
|
|
14
|
+
#
|
|
15
|
+
# screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
|
|
16
|
+
# screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
|
|
17
|
+
#
|
|
18
|
+
# The helpers pass content through verbatim, so input may carry other
|
|
19
|
+
# escape sequences (e.g. {Component::Window} feeds its border string,
|
|
20
|
+
# cursor moves included). For span-aware styling — applying a token to a
|
|
21
|
+
# {StyledString} while preserving per-span colors — use the `*_color`
|
|
22
|
+
# readers instead (e.g. {Component::List} highlights its cursor row via
|
|
23
|
+
# `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
|
|
24
|
+
# helper; structured text → `*_color` reader + {StyledString}.
|
|
25
|
+
#
|
|
26
|
+
# Two built-in themes are provided: {DARK} (the default; the colors Tuile
|
|
27
|
+
# has always used) and {LIGHT} (counterparts legible on light terminal
|
|
28
|
+
# backgrounds). A custom theme is one `with` away:
|
|
29
|
+
#
|
|
30
|
+
# screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
|
|
31
|
+
#
|
|
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
|
+
# ## App-specific tokens
|
|
46
|
+
#
|
|
47
|
+
# Beyond the built-in tokens, an app can carry its own colors in
|
|
48
|
+
# {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
|
|
49
|
+
# {#[]} (fail-fast: a typo raises `KeyError`) and render with the
|
|
50
|
+
# generic {#fg} / {#bg} helpers:
|
|
51
|
+
#
|
|
52
|
+
# theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
|
|
53
|
+
# theme[:accent] # => Color, e.g. for StyledString#with_fg
|
|
54
|
+
# theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
|
|
55
|
+
#
|
|
56
|
+
# Apps wanting semantic readers can subclass — `Data#with` preserves the
|
|
57
|
+
# subclass, so an `AppTheme` stays an `AppTheme` through `with`:
|
|
58
|
+
#
|
|
59
|
+
# class AppTheme < Tuile::Theme
|
|
60
|
+
# def accent(text) = fg(:accent, text)
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# Pair the dark and light variants in a {ThemeDef} and hand it to
|
|
64
|
+
# {Screen#theme_def=} so OS appearance flips pick the right one.
|
|
65
|
+
#
|
|
66
|
+
# @!attribute [r] active_bg_color
|
|
67
|
+
# Background highlight of the component the user is interacting with:
|
|
68
|
+
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
69
|
+
# {Component::TextArea} well, the focused {Component::Button}. "Active"
|
|
70
|
+
# matches the {Component#active?} focus-chain flag — this is the
|
|
71
|
+
# focus/selection highlight in conventional UI terms.
|
|
72
|
+
# @return [Color]
|
|
73
|
+
# @!attribute [r] active_border_color
|
|
74
|
+
# Foreground of a {Component::Window} border when the window is on the
|
|
75
|
+
# active (focus) chain.
|
|
76
|
+
# @return [Color]
|
|
77
|
+
# @!attribute [r] input_bg_color
|
|
78
|
+
# Resting background "well" of {Component::TextField} /
|
|
79
|
+
# {Component::TextArea} when *not* active — visibly a field, but
|
|
80
|
+
# distinctly subtler than {#active_bg_color}.
|
|
81
|
+
# @return [Color]
|
|
82
|
+
# @!attribute [r] hint_color
|
|
83
|
+
# Foreground of keyboard-shortcut captions in status-bar hints (the
|
|
84
|
+
# "quit" in "q quit") — see {#hint}.
|
|
85
|
+
# @return [Color]
|
|
86
|
+
# @!attribute [r] custom
|
|
87
|
+
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
88
|
+
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
89
|
+
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
90
|
+
# the tokens.
|
|
91
|
+
# @return [Hash{Symbol => Color}]
|
|
92
|
+
class Theme < Data.define(:active_bg_color, :active_border_color, :input_bg_color, :hint_color, :custom)
|
|
93
|
+
# @param active_bg_color [Color]
|
|
94
|
+
# @param active_border_color [Color]
|
|
95
|
+
# @param input_bg_color [Color]
|
|
96
|
+
# @param hint_color [Color]
|
|
97
|
+
# @param custom [Hash{Symbol => Color}] app-specific tokens, see {#custom}.
|
|
98
|
+
# @raise [TypeError] when a token is not a {Color}, or `custom` is not a
|
|
99
|
+
# `Hash{Symbol => Color}`.
|
|
100
|
+
def initialize(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: {})
|
|
101
|
+
{ active_bg_color:, active_border_color:, input_bg_color:, hint_color: }.each do |name, value|
|
|
102
|
+
raise TypeError, "#{name} must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
|
|
103
|
+
end
|
|
104
|
+
raise TypeError, "custom must be a Hash, got #{custom.inspect}" unless custom.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
custom.each do |key, value|
|
|
107
|
+
raise TypeError, "custom key must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
|
|
108
|
+
raise TypeError, "custom[#{key.inspect}] must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
|
|
109
|
+
end
|
|
110
|
+
super(active_bg_color:, active_border_color:, input_bg_color:, hint_color:, custom: custom.dup.freeze)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Looks up an app-specific token from {#custom}.
|
|
114
|
+
# @param token [Symbol]
|
|
115
|
+
# @return [Color]
|
|
116
|
+
# @raise [KeyError] when the token is not present — a typo should fail
|
|
117
|
+
# loudly, not paint in a default.
|
|
118
|
+
def [](token) = custom.fetch(token)
|
|
119
|
+
|
|
120
|
+
# Renders `text` in the foreground color of the app-specific `token`
|
|
121
|
+
# — the generic counterpart of {#hint} for {#custom} tokens.
|
|
122
|
+
# @param token [Symbol]
|
|
123
|
+
# @param text [String]
|
|
124
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
125
|
+
# @raise [KeyError] when the token is not present.
|
|
126
|
+
def fg(token, text) = wrap(text, self[token], :fg)
|
|
127
|
+
|
|
128
|
+
# Renders `text` on the background color of the app-specific `token`
|
|
129
|
+
# — the generic counterpart of {#active_bg} for {#custom} tokens.
|
|
130
|
+
# @param token [Symbol]
|
|
131
|
+
# @param text [String]
|
|
132
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
133
|
+
# @raise [KeyError] when the token is not present.
|
|
134
|
+
def bg(token, text) = wrap(text, self[token], :bg)
|
|
135
|
+
|
|
136
|
+
# Renders `text` on the {#active_bg_color} background.
|
|
137
|
+
# @param text [String]
|
|
138
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
139
|
+
def active_bg(text) = wrap(text, active_bg_color, :bg)
|
|
140
|
+
|
|
141
|
+
# Renders `text` in the {#active_border_color} foreground. Content
|
|
142
|
+
# passes through verbatim, so it may embed non-SGR escapes (cursor
|
|
143
|
+
# moves in a border string).
|
|
144
|
+
# @param text [String]
|
|
145
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
146
|
+
def active_border(text) = wrap(text, active_border_color, :fg)
|
|
147
|
+
|
|
148
|
+
# Renders `text` on the {#input_bg_color} background.
|
|
149
|
+
# @param text [String]
|
|
150
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
151
|
+
def input_bg(text) = wrap(text, input_bg_color, :bg)
|
|
152
|
+
|
|
153
|
+
# Renders `text` in the {#hint_color} foreground, for status-bar hints,
|
|
154
|
+
# e.g. `"q #{screen.theme.hint("quit")}"`. The color is baked into the
|
|
155
|
+
# returned String, so strings built this way do *not* restyle when the
|
|
156
|
+
# theme changes — rebuild them instead (the framework's own call sites
|
|
157
|
+
# rebuild on every status-bar refresh).
|
|
158
|
+
# @param text [String]
|
|
159
|
+
# @return [String] ANSI-rendered text, ending with an SGR reset.
|
|
160
|
+
def hint(text) = wrap(text, hint_color, :fg)
|
|
161
|
+
|
|
162
|
+
# The colors Tuile used before themes existed, tuned for dark terminal
|
|
163
|
+
# backgrounds. GREY37 (palette 59) is what Rainbow emits for
|
|
164
|
+
# `:darkslategray`, LIGHT_SKY_BLUE3 (109) for `:cadetblue`; GREY27
|
|
165
|
+
# (238, ~#444444) sits in the grayscale ramp, bright enough to stand
|
|
166
|
+
# out against non-pure-black dark terminal themes (Gruvbox/Solarized/
|
|
167
|
+
# OneDark base backgrounds sit in the #1d–#2d range) yet distinctly
|
|
168
|
+
# darker than the active highlight at 59 (~#5f5f5f).
|
|
169
|
+
# @return [Theme]
|
|
170
|
+
DARK = new(active_bg_color: Color::GREY37,
|
|
171
|
+
active_border_color: Color::GREEN,
|
|
172
|
+
input_bg_color: Color::GREY27,
|
|
173
|
+
hint_color: Color::LIGHT_SKY_BLUE3)
|
|
174
|
+
|
|
175
|
+
# Counterparts legible on light terminal backgrounds: grayscale-ramp
|
|
176
|
+
# highlights just below white (GREY82 = 252 ~#d0d0d0, GREY85 = 253
|
|
177
|
+
# ~#dadada — dark enough to read as a "well" against white, one step
|
|
178
|
+
# lighter than the active highlight) and a dark teal (TURQUOISE4 = 30,
|
|
179
|
+
# ~#008787) keeping the hint hue. `active_border_color` stays the
|
|
180
|
+
# named green — named ANSI colors are remapped by the terminal's own
|
|
181
|
+
# palette, so the theme picks a light-appropriate green for us.
|
|
182
|
+
# @return [Theme]
|
|
183
|
+
LIGHT = new(active_bg_color: Color::GREY82,
|
|
184
|
+
active_border_color: Color::GREEN,
|
|
185
|
+
input_bg_color: Color::GREY85,
|
|
186
|
+
hint_color: Color::TURQUOISE4)
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# The single sanctioned place for verbatim SGR wrapping: `text` is not
|
|
191
|
+
# parsed or validated, so callers may embed non-SGR escapes. Emits the
|
|
192
|
+
# same bytes `StyledString.styled(text, ...).to_ansi` would for plain
|
|
193
|
+
# text.
|
|
194
|
+
# @param text [String]
|
|
195
|
+
# @param color [Color]
|
|
196
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
197
|
+
# @return [String]
|
|
198
|
+
def wrap(text, color, target)
|
|
199
|
+
"#{color.to_ansi(target)}#{text}#{Ansi::RESET}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# An app's theme definition: the {Theme} pair covering both terminal
|
|
5
|
+
# appearances. {Screen} keeps one at {Screen#theme_def} (defaulting to
|
|
6
|
+
# {DEFAULT}) and picks the member matching the detected background at
|
|
7
|
+
# startup and on every OS appearance flip (mode 2031) — so a custom
|
|
8
|
+
# definition survives the user toggling light/dark, where a bare
|
|
9
|
+
# {Screen#theme=} assignment would be replaced.
|
|
10
|
+
#
|
|
11
|
+
# APP_THEME = Tuile::ThemeDef.new(
|
|
12
|
+
# dark: Tuile::Theme::DARK.with(custom: { accent: Color::DARK_ORANGE }),
|
|
13
|
+
# light: Tuile::Theme::LIGHT.with(custom: { accent: Color::DARK_ORANGE3 })
|
|
14
|
+
# )
|
|
15
|
+
# screen.theme_def = APP_THEME
|
|
16
|
+
#
|
|
17
|
+
# Both members must declare the same {Theme#custom} key set. Without
|
|
18
|
+
# that, a token present only in one member would raise `KeyError` at
|
|
19
|
+
# the unpredictable moment the user flips OS appearance; checking here
|
|
20
|
+
# turns it into an immediate construction-time failure.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] dark
|
|
23
|
+
# The theme applied on dark terminal backgrounds.
|
|
24
|
+
# @return [Theme]
|
|
25
|
+
# @!attribute [r] light
|
|
26
|
+
# The theme applied on light terminal backgrounds.
|
|
27
|
+
# @return [Theme]
|
|
28
|
+
class ThemeDef < Data.define(:dark, :light)
|
|
29
|
+
# @param dark [Theme]
|
|
30
|
+
# @param light [Theme]
|
|
31
|
+
# @raise [TypeError] when a member is not a {Theme}.
|
|
32
|
+
# @raise [ArgumentError] when the members' {Theme#custom} key sets differ.
|
|
33
|
+
def initialize(dark:, light:)
|
|
34
|
+
raise TypeError, "dark must be a Tuile::Theme, got #{dark.inspect}" unless dark.is_a?(Theme)
|
|
35
|
+
raise TypeError, "light must be a Tuile::Theme, got #{light.inspect}" unless light.is_a?(Theme)
|
|
36
|
+
|
|
37
|
+
if dark.custom.keys.sort != light.custom.keys.sort
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"dark and light must declare the same custom tokens; " \
|
|
40
|
+
"dark has #{dark.custom.keys.sort.inspect}, light has #{light.custom.keys.sort.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The member for the given color scheme. Anything other than `:light`
|
|
47
|
+
# selects {#dark}, matching {TerminalBackground.detect}'s
|
|
48
|
+
# inconclusive-means-dark policy.
|
|
49
|
+
# @param scheme [Symbol] `:dark` or `:light`.
|
|
50
|
+
# @return [Theme]
|
|
51
|
+
def for(scheme) = scheme == :light ? light : dark
|
|
52
|
+
|
|
53
|
+
# The built-in pair: {Theme::DARK} / {Theme::LIGHT}.
|
|
54
|
+
# @return [ThemeDef]
|
|
55
|
+
DEFAULT = new(dark: Theme::DARK, light: Theme::LIGHT)
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# The definition newly-constructed {Screen}s start from (see
|
|
59
|
+
# {Screen#theme_def}); initially {DEFAULT}. Reassigning affects
|
|
60
|
+
# future screens only — an already-constructed screen keeps its
|
|
61
|
+
# definition until {Screen#theme_def=}.
|
|
62
|
+
#
|
|
63
|
+
# Intended for test suites: production apps assign
|
|
64
|
+
# {Screen#theme_def=} once at startup, but component specs build a
|
|
65
|
+
# fresh {FakeScreen} per example, and a component reading a custom
|
|
66
|
+
# token (`theme[:accent]`) would `KeyError` against the built-in
|
|
67
|
+
# default. Point this at the app's definition once and every
|
|
68
|
+
# {Screen.fake} carries it:
|
|
69
|
+
#
|
|
70
|
+
# Tuile::ThemeDef.default = APP_THEME # spec_helper.rb, once
|
|
71
|
+
# before { Screen.fake } # theme[:accent] resolves
|
|
72
|
+
# @return [ThemeDef]
|
|
73
|
+
attr_reader :default
|
|
74
|
+
|
|
75
|
+
# @param theme_def [ThemeDef]
|
|
76
|
+
# @raise [TypeError] when not a {ThemeDef}.
|
|
77
|
+
def default=(theme_def)
|
|
78
|
+
raise TypeError, "expected ThemeDef, got #{theme_def.inspect}" unless theme_def.is_a?(ThemeDef)
|
|
79
|
+
|
|
80
|
+
@default = theme_def
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
@default = DEFAULT
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/tuile/version.rb
CHANGED