tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/theme.rb
CHANGED
|
@@ -48,6 +48,29 @@ module Tuile
|
|
|
48
48
|
# own. Baked content colors ({Component::Label} text and friends) can't:
|
|
49
49
|
# they live in a frozen {StyledString} and still need the hook.
|
|
50
50
|
#
|
|
51
|
+
# ## Derived tokens
|
|
52
|
+
#
|
|
53
|
+
# Any token — chrome or {#custom} — may be a `Proc` of the terminal's
|
|
54
|
+
# background ({Screen#background_color}) instead of a {Color}, for a color
|
|
55
|
+
# that must sit right on whatever background the user has:
|
|
56
|
+
#
|
|
57
|
+
# LIFT = ->(color, by) { Color.rgb(*color.rgb.map { (_1 + by).clamp(0, 255) }) }
|
|
58
|
+
#
|
|
59
|
+
# Theme::DARK.with(custom: {
|
|
60
|
+
# pane_bg: ->(bg) { bg ? LIFT.call(bg, 10) : Color::GREY11 },
|
|
61
|
+
# pane_frame: ->(_bg, t) { LIFT.call(t[:pane_bg], 20) }
|
|
62
|
+
# })
|
|
63
|
+
#
|
|
64
|
+
# The Proc takes the background (`nil` when the terminal reported none — the
|
|
65
|
+
# normal case, so always keep a fallback) and optionally a {Resolver} for
|
|
66
|
+
# reading sibling tokens, in any declaration order. It returns a {Color};
|
|
67
|
+
# Tuile ships no color arithmetic, so the math is the app's.
|
|
68
|
+
#
|
|
69
|
+
# {Screen} calls {#resolve} whenever the theme or the background changes, so
|
|
70
|
+
# {Screen#theme} is always concrete and {Ref}s and `*_color` readers never
|
|
71
|
+
# see a Proc. Reading a derived token of an *unresolved* theme raises
|
|
72
|
+
# {Tuile::Error}.
|
|
73
|
+
#
|
|
51
74
|
# @!attribute [r] active_bg_color
|
|
52
75
|
# Background highlight of the component the user is interacting with:
|
|
53
76
|
# the {Component::List} cursor row, the focused {Component::TextField} /
|
|
@@ -87,41 +110,45 @@ module Tuile
|
|
|
87
110
|
# no caret) shows no focus at all.
|
|
88
111
|
# @return [Color]
|
|
89
112
|
# @!attribute [r] scrollbar_color
|
|
90
|
-
# Foreground of the {VerticalScrollBar} a {Component::List}
|
|
91
|
-
# {Component::TextView}
|
|
92
|
-
# alike, which the glyphs' own ink densities tell
|
|
113
|
+
# Foreground of the {Component::VerticalScrollBar} a {Component::List},
|
|
114
|
+
# {Component::TextView} or {Component::Scroller} puts down its right
|
|
115
|
+
# edge — handle and track alike, which the glyphs' own ink densities tell
|
|
116
|
+
# apart.
|
|
93
117
|
# @return [Color]
|
|
94
118
|
# @!attribute [r] custom
|
|
95
119
|
# App-specific color tokens; empty in the built-in themes. Frozen —
|
|
96
120
|
# build a changed theme via `with(custom: ...)`. Prefer {#[]} for
|
|
97
121
|
# lookups (it fail-fasts on typos); read this directly to enumerate
|
|
98
|
-
# the tokens.
|
|
99
|
-
# @return [Hash{Symbol => Color}]
|
|
122
|
+
# the tokens. An unresolved theme's values may be derivation Procs.
|
|
123
|
+
# @return [Hash{Symbol => Color, Proc}]
|
|
100
124
|
class Theme < Data.define(:active_bg_color, :active_border_color, :input_bg_color,
|
|
101
125
|
:placeholder_color, :error_color, :error_bg_color, :error_active_bg_color,
|
|
102
126
|
:scrollbar_color, :custom)
|
|
103
|
-
# @param active_bg_color [Color]
|
|
104
|
-
# @param active_border_color [Color]
|
|
105
|
-
# @param input_bg_color [Color]
|
|
106
|
-
# @param placeholder_color [Color]
|
|
107
|
-
# @param error_color [Color]
|
|
108
|
-
# @param error_bg_color [Color]
|
|
109
|
-
# @param error_active_bg_color [Color]
|
|
110
|
-
# @param scrollbar_color [Color]
|
|
111
|
-
# @param custom [Hash{Symbol => Color}] app-specific tokens, see {#custom}.
|
|
112
|
-
# @raise [TypeError] when a token is
|
|
113
|
-
# `Hash
|
|
127
|
+
# @param active_bg_color [Color, Proc]
|
|
128
|
+
# @param active_border_color [Color, Proc]
|
|
129
|
+
# @param input_bg_color [Color, Proc]
|
|
130
|
+
# @param placeholder_color [Color, Proc]
|
|
131
|
+
# @param error_color [Color, Proc]
|
|
132
|
+
# @param error_bg_color [Color, Proc]
|
|
133
|
+
# @param error_active_bg_color [Color, Proc]
|
|
134
|
+
# @param scrollbar_color [Color, Proc]
|
|
135
|
+
# @param custom [Hash{Symbol => Color, Proc}] app-specific tokens, see {#custom}.
|
|
136
|
+
# @raise [TypeError] when a token is neither a {Color} nor a Proc, or
|
|
137
|
+
# `custom` is not a Hash with Symbol keys.
|
|
138
|
+
# @raise [ArgumentError] when a derivation Proc requires more than two
|
|
139
|
+
# arguments.
|
|
114
140
|
def initialize(active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
|
|
115
141
|
error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color:, custom: {})
|
|
116
142
|
{ active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
|
|
117
143
|
error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color: }.each do |name, value|
|
|
118
|
-
|
|
144
|
+
Theme.validate_token(name.to_s, value)
|
|
119
145
|
end
|
|
120
146
|
raise TypeError, "custom must be a Hash, got #{custom.inspect}" unless custom.is_a?(Hash)
|
|
121
147
|
|
|
122
148
|
custom.each do |key, value|
|
|
123
149
|
raise TypeError, "custom key must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
|
|
124
|
-
|
|
150
|
+
|
|
151
|
+
Theme.validate_token("custom[#{key.inspect}]", value)
|
|
125
152
|
end
|
|
126
153
|
super(active_bg_color:, active_border_color:, input_bg_color:, placeholder_color:,
|
|
127
154
|
error_color:, error_bg_color:, error_active_bg_color:, scrollbar_color:, custom: custom.dup.freeze)
|
|
@@ -132,7 +159,9 @@ module Tuile
|
|
|
132
159
|
# @return [Color]
|
|
133
160
|
# @raise [KeyError] when the token is not present — a typo should fail
|
|
134
161
|
# loudly, not paint in a default.
|
|
135
|
-
|
|
162
|
+
# @raise [Tuile::Error] when the token is derived and this theme is
|
|
163
|
+
# unresolved.
|
|
164
|
+
def [](token) = Theme.concrete(custom.fetch(token), token)
|
|
136
165
|
|
|
137
166
|
# The built-in chrome color tokens — every {Data} member bar {#custom}. A
|
|
138
167
|
# {Ref} resolves a name in this set as the chrome color; anything else as a
|
|
@@ -140,6 +169,128 @@ module Tuile
|
|
|
140
169
|
# @return [Array<Symbol>]
|
|
141
170
|
CHROME_TOKENS = (members - %i[custom]).freeze
|
|
142
171
|
|
|
172
|
+
# A derived chrome token of an unresolved theme must not reach paint code,
|
|
173
|
+
# where a Proc handed to `with_fg` fails far from the cause.
|
|
174
|
+
CHROME_TOKENS.each do |name|
|
|
175
|
+
define_method(name) { Theme.concrete(super(), name) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [Boolean] whether any token, chrome or {#custom}, is a
|
|
179
|
+
# derivation Proc — false for every theme {#resolve} returns.
|
|
180
|
+
def derived? = to_h.any? { |name, value| name == :custom ? value.values.any?(Proc) : value.is_a?(Proc) }
|
|
181
|
+
|
|
182
|
+
# A copy with every derivation Proc called and replaced by the {Color} it
|
|
183
|
+
# returned; `self` when nothing is derived.
|
|
184
|
+
#
|
|
185
|
+
# theme.resolve(Color.rgb(30, 30, 46))[:pane_bg] # => Color.rgb(40, 40, 56)
|
|
186
|
+
#
|
|
187
|
+
# {Screen} calls this itself; an app needs it only to read a derived token
|
|
188
|
+
# outside a screen.
|
|
189
|
+
# @param background [Color, nil] the terminal background the Procs derive from.
|
|
190
|
+
# @return [Theme] of the receiver's class, with no Procs left.
|
|
191
|
+
# @raise [ArgumentError] when derived tokens read each other in a cycle.
|
|
192
|
+
# @raise [TypeError] when a Proc returns something other than a {Color}.
|
|
193
|
+
def resolve(background)
|
|
194
|
+
return self unless derived?
|
|
195
|
+
|
|
196
|
+
resolver = Resolver.new(self, background)
|
|
197
|
+
with(**CHROME_TOKENS.to_h { [_1, resolver.public_send(_1)] },
|
|
198
|
+
custom: custom.keys.to_h { [_1, resolver[_1]] })
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# What a derivation Proc gets as its second argument: the theme being
|
|
202
|
+
# resolved, read the way paint code reads a resolved one — `t[:pane_bg]`,
|
|
203
|
+
# `t.input_bg_color` — with a derived sibling resolved on first read, so
|
|
204
|
+
# declaration order does not matter.
|
|
205
|
+
class Resolver
|
|
206
|
+
# @param theme [Theme] the unresolved theme.
|
|
207
|
+
# @param background [Color, nil]
|
|
208
|
+
# @api private
|
|
209
|
+
def initialize(theme, background)
|
|
210
|
+
@chrome = theme.to_h.except(:custom)
|
|
211
|
+
@custom = theme.custom
|
|
212
|
+
@background = background
|
|
213
|
+
@done = {}
|
|
214
|
+
@resolving = []
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @param token [Symbol] a {Theme#custom} token.
|
|
218
|
+
# @return [Color]
|
|
219
|
+
# @raise [KeyError] when the token is not present.
|
|
220
|
+
def [](token) = resolve_token([:custom, token], @custom.fetch(token))
|
|
221
|
+
|
|
222
|
+
CHROME_TOKENS.each do |name|
|
|
223
|
+
define_method(name) { resolve_token([:chrome, name], @chrome.fetch(name)) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# @param key [Array(Symbol, Symbol)] the namespace and name, since a
|
|
229
|
+
# custom token may share a chrome token's name.
|
|
230
|
+
# @param value [Color, Proc]
|
|
231
|
+
# @return [Color]
|
|
232
|
+
def resolve_token(key, value)
|
|
233
|
+
return @done[key] if @done.key?(key)
|
|
234
|
+
return @done[key] = value unless value.is_a?(Proc)
|
|
235
|
+
|
|
236
|
+
if @resolving.include?(key)
|
|
237
|
+
path = [*@resolving, key].map { |(kind, name)| kind == :custom ? "[#{name.inspect}]" : name.to_s }
|
|
238
|
+
raise ArgumentError, "derived theme tokens form a cycle: #{path.join(" -> ")}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
@resolving << key
|
|
242
|
+
color = value.call(*[@background, self].first(Theme.derivation_arity(value)))
|
|
243
|
+
@resolving.pop
|
|
244
|
+
raise TypeError, "#{key.last.inspect} derived #{color.inspect}, not a Tuile::Color" unless color.is_a?(Color)
|
|
245
|
+
|
|
246
|
+
@done[key] = color
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
class << self
|
|
251
|
+
# @param name [String] the token, for the message.
|
|
252
|
+
# @param value [Color, Proc]
|
|
253
|
+
# @return [void]
|
|
254
|
+
# @raise [TypeError, ArgumentError]
|
|
255
|
+
# @api private
|
|
256
|
+
def validate_token(name, value)
|
|
257
|
+
return derivation_arity(value, name) if value.is_a?(Proc)
|
|
258
|
+
return if value.is_a?(Color)
|
|
259
|
+
|
|
260
|
+
raise TypeError, "#{name} must be a Tuile::Color or a Proc, got #{value.inspect}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# How many of `(background, resolver)` a derivation Proc is called with —
|
|
264
|
+
# both when it takes optional or splat parameters, as {Listeners} does.
|
|
265
|
+
# @param proc [Proc]
|
|
266
|
+
# @param name [String, nil] the token, for the message.
|
|
267
|
+
# @return [Integer] 0, 1 or 2.
|
|
268
|
+
# @raise [ArgumentError] when the Proc requires more than two arguments.
|
|
269
|
+
# @api private
|
|
270
|
+
def derivation_arity(proc, name = nil)
|
|
271
|
+
arity = proc.arity
|
|
272
|
+
required = arity.negative? ? -arity - 1 : arity
|
|
273
|
+
if required > 2
|
|
274
|
+
raise ArgumentError, "#{name || "a derived token"} takes (background, theme) at most, " \
|
|
275
|
+
"but #{proc.inspect} requires #{required} arguments"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
arity.negative? ? 2 : arity
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @param value [Color, Proc]
|
|
282
|
+
# @param name [Symbol]
|
|
283
|
+
# @return [Color]
|
|
284
|
+
# @raise [Tuile::Error] when `value` is a Proc.
|
|
285
|
+
# @api private
|
|
286
|
+
def concrete(value, name)
|
|
287
|
+
return value unless value.is_a?(Proc)
|
|
288
|
+
|
|
289
|
+
raise Tuile::Error, "#{name.inspect} is derived and this theme is unresolved; " \
|
|
290
|
+
"read it from Screen#theme, or call resolve(background)"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
143
294
|
# @param name [Symbol] a token name.
|
|
144
295
|
# @return [Boolean] true iff `name` is a built-in chrome token (see
|
|
145
296
|
# {CHROME_TOKENS}) rather than a {#custom} one.
|
data/lib/tuile/theme_def.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Tuile
|
|
|
14
14
|
# )
|
|
15
15
|
# screen.theme_def = APP_THEME
|
|
16
16
|
#
|
|
17
|
+
# A member may carry derived tokens ({Theme}'s *Derived tokens*); the
|
|
18
|
+
# screen resolves the member it picks against {Screen#background_color},
|
|
19
|
+
# and again whenever that changes.
|
|
20
|
+
#
|
|
17
21
|
# Both members must declare the same {Theme#custom} key set. Without
|
|
18
22
|
# that, a token present only in one member would raise `KeyError` at
|
|
19
23
|
# the unpredictable moment the user flips OS appearance; checking here
|
data/lib/tuile/version.rb
CHANGED
data/lib/tuile.rb
CHANGED
|
@@ -30,6 +30,59 @@ module Tuile
|
|
|
30
30
|
def logger
|
|
31
31
|
@logger ||= Logger.new(IO::NULL)
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
# How a pre-settle {Component#rect} read is reported: `false`, `:warn` to
|
|
35
|
+
# {#logger}, or `:raise`. Unset — which is how a process starts — answers
|
|
36
|
+
# the default: **`:raise` under a {FakeScreen}**, where a spec suite is the
|
|
37
|
+
# audience, and `false` anywhere else. See {StrictLayout}.
|
|
38
|
+
# @return [Symbol, false]
|
|
39
|
+
def strict_layout
|
|
40
|
+
return @strict_layout unless @strict_layout.nil?
|
|
41
|
+
|
|
42
|
+
Screen.instance? && Screen.instance.is_a?(FakeScreen) ? :raise : false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Chooses for the whole process, overriding that default either way —
|
|
46
|
+
# `true` means `:raise`, and `nil` hands the choice back:
|
|
47
|
+
#
|
|
48
|
+
# Tuile.strict_layout = :warn # a running app, being watched
|
|
49
|
+
# Tuile.strict_layout = false # a spec suite that wants none of it
|
|
50
|
+
#
|
|
51
|
+
# A mode that isn't `false` prepends {StrictLayout} into {Component}, which
|
|
52
|
+
# is permanent for the process; `false` afterwards makes the check inert
|
|
53
|
+
# rather than removing it.
|
|
54
|
+
# @param mode [Symbol, Boolean, nil] `:warn`, `:raise`, `false`, or `nil`
|
|
55
|
+
# for the default.
|
|
56
|
+
# @raise [ArgumentError] on any other value.
|
|
57
|
+
# @return [void]
|
|
58
|
+
def strict_layout=(mode)
|
|
59
|
+
mode = :raise if mode == true
|
|
60
|
+
unless [nil, false, :warn, :raise].include?(mode)
|
|
61
|
+
raise ArgumentError, "expected :warn, :raise, false or nil, got #{mode.inspect}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
StrictLayout.install if mode
|
|
65
|
+
@strict_layout = mode
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Runs the block with the diagnostic off, for a *spec's* read taken
|
|
69
|
+
# pre-settle *on purpose* — asserting that a rect survived a round trip is a
|
|
70
|
+
# question only the unsettled value answers:
|
|
71
|
+
#
|
|
72
|
+
# Tuile.without_strict_layout { assert_equal rect, second.rect }
|
|
73
|
+
#
|
|
74
|
+
# A test tool, not app code: the diagnostic is on only under a {FakeScreen}
|
|
75
|
+
# or after {.strict_layout=}, so an app's stale read wants settling, not
|
|
76
|
+
# silencing. Restores whatever was in force, default included, and silences
|
|
77
|
+
# every read in the block, on this thread and any other.
|
|
78
|
+
# @return [Object] the block's value.
|
|
79
|
+
def without_strict_layout
|
|
80
|
+
previous = @strict_layout
|
|
81
|
+
@strict_layout = false
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
@strict_layout = previous
|
|
85
|
+
end
|
|
33
86
|
end
|
|
34
87
|
|
|
35
88
|
loader = Zeitwerk::Loader.for_gem
|