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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. 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} or
91
- # {Component::TextView} paints down its right edge — handle and track
92
- # alike, which the glyphs' own ink densities tell apart.
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 not a {Color}, or `custom` is not a
113
- # `Hash{Symbol => Color}`.
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
- raise TypeError, "#{name} must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
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
- raise TypeError, "custom[#{key.inspect}] must be a Tuile::Color, got #{value.inspect}" unless value.is_a?(Color)
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
- def [](token) = custom.fetch(token)
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.
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.16.0"
5
+ VERSION = "0.17.0"
6
6
  end
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