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
@@ -13,7 +13,7 @@ module Tuile
13
13
  # The pane owns no chrome of its own — no status bar, no reserved row.
14
14
  # {#content} gets the full pane rect, and an app that wants a status line
15
15
  # builds one into its own layout and drives it from
16
- # {Screen#on_focus_changed=} (`D_status_bar`).
16
+ # {Screen#on_focus_changed} (`D_status_bar`).
17
17
  #
18
18
  # The pane is not a {Component::Layout}: popups deliberately overlap content
19
19
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -27,6 +27,10 @@ module Tuile
27
27
  # user was, instead of falling through to {#content} and getting
28
28
  # cascaded to the first focusable child.
29
29
  @popup_prior_focus = {}
30
+ # Where each open popup wants to be, and the anchor rect its last
31
+ # placement used (`:lost` once the anchor went away) — see #relayout.
32
+ @placements = {}.compare_by_identity
33
+ @placed_anchors = {}.compare_by_identity
30
34
  end
31
35
 
32
36
  # @return [Component, nil] the tiled content component.
@@ -52,14 +56,13 @@ module Tuile
52
56
  remove_child(@content) unless @content.nil?
53
57
  @content = content
54
58
  add_child(content, at: 0) # the tiled layer paints beneath everything else
55
- layout
56
59
  end
57
60
 
58
- # Adds an overlay and invalidates it for repaint. A {Component::Popup} is
59
- # centered and grabs focus; a bare {Component::Overlay} is left wherever
60
- # the caller positioned it and does *not* take focus, so the component that
61
- # was focused keeps the cursor and keeps receiving keys — the overlay
62
- # floats above the content, driven from app code.
61
+ # Adds an overlay at `placement` and invalidates it for repaint; the next
62
+ # settle gives it its rect. A {Component::Popup} grabs focus; a bare
63
+ # {Component::Overlay} does *not*, so the component that was focused keeps
64
+ # the cursor and keeps receiving keys — the overlay floats above the
65
+ # content, driven from app code.
63
66
  #
64
67
  # The *whole subtree* is invalidated, not just the overlay wrapper (which
65
68
  # paints nothing on its own): a reopened popup may land on cells that the
@@ -67,21 +70,48 @@ module Tuile
67
70
  # last time its content components won't re-invalidate themselves — so
68
71
  # without this the overlay's contents would stay blank on reopen.
69
72
  # @param window [Component::Overlay] any overlay, modal or not.
73
+ # @param placement [Component::Overlay::Placement, nil] where it wants to
74
+ # be; `nil` takes its {Component::Overlay#default_placement}.
75
+ # @raise [TypeError] if `placement` does not include
76
+ # {Component::Overlay::Placement}.
70
77
  # @return [void]
71
- def add_popup(window)
78
+ def add_popup(window, placement = nil)
72
79
  raise TypeError, "expected Overlay, got #{window.inspect}" unless window.is_a? Component::Overlay
73
80
  raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
74
81
 
82
+ placement ||= window.default_placement
83
+ check_placement(placement)
75
84
  @popup_prior_focus[window] = screen.focused
85
+ @placements[window] = placement
76
86
  @popups << window
77
87
  add_child(window) # appended: popups paint over the tiled content
78
- if window.modal?
79
- window.center
80
- screen.focused = window
81
- end
88
+ screen.focused = window if window.modal?
82
89
  window.walk_tree { |c| screen.invalidate(c) }
83
90
  end
84
91
 
92
+ # @param popup [Component::Overlay] an open popup.
93
+ # @return [Component::Overlay::Placement, nil] where it wants to be; `nil`
94
+ # if it isn't open here.
95
+ def placement(popup) = @placements[popup]
96
+
97
+ # Moves an open popup; it takes the new rect on the next settle.
98
+ # {Component::Overlay#placement=} is the usual way in.
99
+ # @param popup [Component::Overlay] an open popup.
100
+ # @param placement [Component::Overlay::Placement] where it wants to be.
101
+ # @raise [ArgumentError] if `popup` isn't open on this pane.
102
+ # @raise [TypeError] if `placement` does not include
103
+ # {Component::Overlay::Placement}.
104
+ # @return [void]
105
+ def constrain(popup, placement)
106
+ raise ArgumentError, "#{popup} is not an open popup on this pane" unless has_popup?(popup)
107
+
108
+ check_placement(placement)
109
+ return if @placements[popup] == placement
110
+
111
+ @placements[popup] = placement
112
+ invalidate_layout
113
+ end
114
+
85
115
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
86
116
  # remaining popup, falling back to the focus snapshotted when the popup
87
117
  # was opened (if still attached), then to {#content}, then to nil.
@@ -91,6 +121,8 @@ module Tuile
91
121
  raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
92
122
 
93
123
  prior = @popup_prior_focus.delete(window)
124
+ @placements.delete(window)
125
+ @placed_anchors.delete(window)
94
126
  @removing_popup_prior = prior
95
127
  remove_child(window)
96
128
  # Runs after the detach, so a prior pointing *inside* the removed popup is
@@ -118,6 +150,8 @@ module Tuile
118
150
  @content = nil
119
151
  @popups.clear
120
152
  @popup_prior_focus.clear
153
+ @placements.clear
154
+ @placed_anchors.clear
121
155
  end
122
156
 
123
157
  # @param window [Component]
@@ -131,30 +165,40 @@ module Tuile
131
165
  # content without capturing input.
132
166
  def modal_popup = @popups.reverse_each.find(&:modal?)
133
167
 
134
- # Re-lays out children whenever the pane's own rect changes.
135
- # @param new_rect [Rect]
136
- # @return [void]
137
- def rect=(new_rect)
138
- super
139
- layout
140
- end
168
+ # The root of the current **key scope**: the topmost modal popup when one
169
+ # is open, else the tiled {#content}. Keys bubble up to it and no further,
170
+ # a paste reaches only a focus chain inside it, and Tab cycles only the
171
+ # stops beneath it — so a component outside it is one the keyboard cannot
172
+ # reach at all. The mouse resolves against a *point* instead
173
+ # ({#mouse_root_at}).
174
+ # @return [Component, nil] nil when the pane holds neither.
175
+ def key_scope = modal_popup || @content
141
176
 
142
177
  # Gives {#content} the whole pane rect — the pane reserves nothing for
143
- # itself. Each popup re-resolves its {Component::Popup#declared_size} against the new
144
- # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
145
- # resize — repositioning itself (modal popups recenter; non-modal overlays
146
- # keep the top-left their owner assigned).
178
+ # itself — and each popup the rect its placement asks for, in stacking
179
+ # order, so a submenu is placed after the panel it hangs from.
180
+ #
181
+ # Re-running it moves nothing that stood still: a placement is a rule, so
182
+ # a second popup opening re-derives the first one's rect unchanged.
183
+ #
184
+ # An empty pane collapses every popup rather than placing it — an `At` rect
185
+ # would otherwise stand in a pane with no cells — and has no `return if
186
+ # rect.empty?` guard, which would strand the content at its last rect
187
+ # (`D_empty_ancestor`).
147
188
  # @return [void]
148
- def layout
149
- return if rect.empty?
150
-
151
- @content&.rect = rect
152
- @popups.each(&:reposition)
189
+ def relayout
190
+ @content&.rect = local_rect
191
+ if rect.empty?
192
+ @popups.each { _1.rect = Rect.new(0, 0, 0, 0) }
193
+ else
194
+ @popups.each { place(_1) }
195
+ end
153
196
  end
154
197
 
155
198
  # Pane paints nothing itself; its children paint over the entire rect.
199
+ # @param _canvas [Canvas] see {Component#repaint}.
156
200
  # @return [void]
157
- def repaint; end
201
+ def repaint(_canvas); end
158
202
 
159
203
  # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
160
204
  # the first component whose `handle_key?` returns true wins.
@@ -174,7 +218,7 @@ module Tuile
174
218
  # @param key [String]
175
219
  # @return [Boolean] true if the key was handled.
176
220
  def handle_key?(key)
177
- scope = modal_popup || @content
221
+ scope = key_scope
178
222
  return false if scope.nil?
179
223
 
180
224
  bubble_key(key, scope)
@@ -190,7 +234,7 @@ module Tuile
190
234
  # @param text [String]
191
235
  # @return [void]
192
236
  def handle_paste(text)
193
- scope = modal_popup || @content
237
+ scope = key_scope
194
238
  return if scope.nil?
195
239
 
196
240
  chain = focus_chain(scope)
@@ -283,6 +327,75 @@ module Tuile
283
327
 
284
328
  private
285
329
 
330
+ # Rejects a non-placement where the app named it, rather than mid-pass: the
331
+ # layout is deferred, so the `NoMethodError` from a missing `rect_for` would
332
+ # otherwise surface a turn later, under this file's backtrace.
333
+ # @param placement [Object]
334
+ # @raise [TypeError] unless it includes {Component::Overlay::Placement}.
335
+ # @return [void]
336
+ def check_placement(placement)
337
+ return if placement.is_a?(Component::Overlay::Placement)
338
+
339
+ raise TypeError, "#{placement.class} must include Tuile::Component::Overlay::Placement"
340
+ end
341
+
342
+ # A placement's {Component::Overlay::Placement#anchor} in screen
343
+ # coordinates: a `Rect` passes through, a component resolves while it is on
344
+ # screen, and `nil` means either unanchored or gone — {#place} tells those
345
+ # apart by whether an anchor was declared at all.
346
+ # @param anchor [Component, Rect, nil]
347
+ # @return [Rect, nil]
348
+ def resolve_anchor(anchor)
349
+ return anchor if anchor.nil? || anchor.is_a?(Rect)
350
+
351
+ ComponentUtil.effectively_visible?(anchor) ? anchor.absolute_extent_rect : nil
352
+ end
353
+
354
+ # @param popup [Component::Overlay]
355
+ # @return [void]
356
+ def place(popup)
357
+ placement = @placements.fetch(popup)
358
+ anchor = placement.anchor
359
+ anchor_rect = resolve_anchor(anchor)
360
+ unless anchor.nil?
361
+ return lose_anchor(popup) if anchor_rect.nil?
362
+
363
+ @placed_anchors[popup] = anchor_rect
364
+ end
365
+ popup.rect = placement.rect_for(popup, rect.size, anchor_rect)
366
+ end
367
+
368
+ # Leaves a popup whose anchor was detached or hidden at its last rect. It
369
+ # warns, once, because closing the popup is its owner's job and an owner
370
+ # that forgot leaves it floating over nothing.
371
+ # @param popup [Component::Overlay]
372
+ # @return [void]
373
+ def lose_anchor(popup)
374
+ return if @placed_anchors[popup] == :lost
375
+
376
+ @placed_anchors[popup] = :lost
377
+ Tuile.logger.warn("#{popup} lost its anchor; left where it was — close it when the anchor goes")
378
+ end
379
+
380
+ # Re-marks the pane when an anchored popup's anchor now resolves elsewhere
381
+ # than where its last placement read it. {Screen#flush_layout} asks once
382
+ # its drain is empty, because this pane's pass runs *before* the content an
383
+ # anchor sits in, so a settle that moved the content handed the pass a
384
+ # stale anchor. Anchors in `lib/` converge in one more round — the content
385
+ # never moves for a popup, and a cascade anchors to a `Rect` — but a popup
386
+ # anchored inside another takes one round per level.
387
+ # @return [Boolean] whether it marked, so the screen drains again.
388
+ def remark_moved_anchors
389
+ return false if rect.empty? # #relayout collapses rather than places then, so nothing records
390
+
391
+ moved = @popups.any? do |popup|
392
+ anchor = @placements[popup].anchor
393
+ !anchor.nil? && (resolve_anchor(anchor) || :lost) != @placed_anchors[popup]
394
+ end
395
+ invalidate_layout if moved
396
+ moved
397
+ end
398
+
286
399
  # The overlays a click counts as landing *inside*: the one it hit, plus
287
400
  # every overlay that one belongs to, up the {Component::Overlay#owner}
288
401
  # chain. An owner is any component, so it is resolved to the overlay
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The stale-rect diagnostic: a {Component#rect} read taken while an ancestor
5
+ # still owes a {Component#relayout} says so, instead of handing back the
6
+ # previous pass's rectangle in silence. **On wherever a {FakeScreen} is the
7
+ # installed screen** — a spec suite needs no setup to get it — and off in an
8
+ # app, which {Tuile.strict_layout} overrides either way:
9
+ #
10
+ # holder.constrain(pane, Rect.new(0, 0, 100, 26))
11
+ # pane.left.rect # => Tuile::Error: read the rect of #<Tuile::Component::Label
12
+ # # rect=(0,0 80x50)> while #<TwoPane rect=(0,0 100x26)> owes a
13
+ # # relayout … at spec/two_pane_spec.rb:42
14
+ #
15
+ # `:warn` logs the same line to {Tuile.logger} and hands the rectangle over,
16
+ # for watching a running app; `:raise` is the default and what a spec wants,
17
+ # since the backtrace names the read and a suite that never set a logger
18
+ # would see nothing at all. For the read that is pre-settle *on purpose*,
19
+ # {Tuile.without_strict_layout}.
20
+ #
21
+ # Reading `size`, `width`, `height`, `local_rect`, `absolute_rect` or
22
+ # `to_screen` reports too — they all go through the one reader.
23
+ #
24
+ # **Only reads the app makes are reported** ({PLUMBING}): a read `lib/` makes
25
+ # on the app's behalf mid-handler is not the app's to fix.
26
+ #
27
+ # == Implementation details
28
+ #
29
+ # {Tuile.strict_layout=} and {FakeScreen} prepend this module into
30
+ # {Component}, so `rect` stays the bare `attr_reader` — the hottest read in
31
+ # the toolkit — in every process that never asks. Nothing unprepends; turning
32
+ # the mode off leaves the check inert. See `D_strict_layout`.
33
+ module StrictLayout
34
+ # Where the gem's own frames live, so the site the message names is the
35
+ # app's — `to_screen` and the `size` / `width` / `height` trio all read
36
+ # `rect` from inside `component.rb`.
37
+ # @return [String]
38
+ LIB_DIR = File.expand_path("..", __dir__)
39
+
40
+ # The thread-local marking a report in progress: {Component#inspect} prints
41
+ # the rect, so building the message re-enters `rect` on the very component
42
+ # that is being complained about.
43
+ # @return [Symbol]
44
+ REPORTING = :tuile_strict_layout_reporting
45
+
46
+ # The readers that only forward to `rect`. A frame of one of these between
47
+ # the read and the app's own code carries no decision of the framework's, so
48
+ # the read still counts as the app's.
49
+ # @return [Array<String>]
50
+ PLUMBING = %w[rect size width height local_rect local_extent_rect absolute_rect absolute_extent_rect
51
+ to_screen to_local].freeze
52
+
53
+ class << self
54
+ # Makes {Component#rect} consult {Tuile.strict_layout} — idempotent, and
55
+ # permanent for the process.
56
+ # @return [void]
57
+ def install = Component.prepend(self)
58
+
59
+ # Reports `component`'s rect read as stale, the way `mode` asks for.
60
+ #
61
+ # @param component [Component] the component whose rect was read.
62
+ # @param ancestor [Component] the ancestor owing the relayout.
63
+ # @param mode [Symbol] `:raise` or `:warn`.
64
+ # @raise [Error] in `:raise` mode, unless the read was the framework's own.
65
+ # @return [void]
66
+ def report(component, ancestor, mode)
67
+ return unless app_read?
68
+
69
+ message = message_for(component, ancestor)
70
+ raise Error, message if mode == :raise
71
+
72
+ Tuile.logger.warn(message)
73
+ end
74
+
75
+ private
76
+
77
+ # @param component [Component]
78
+ # @param ancestor [Component]
79
+ # @return [String]
80
+ def message_for(component, ancestor)
81
+ Thread.current[REPORTING] = true
82
+ "Tuile: read the rect of #{component.inspect} while #{ancestor.inspect} owes a relayout — " \
83
+ "that rectangle is the previous pass's. Call flush_layout before reading it (a spec), or " \
84
+ "read it after the next event (an app)#{site}"
85
+ ensure
86
+ Thread.current[REPORTING] = false
87
+ end
88
+
89
+ # Whether the app asked the question rather than the framework on its
90
+ # behalf: the frames between the read and the first one outside the gem
91
+ # are {PLUMBING} and nothing else.
92
+ # @return [Boolean]
93
+ def app_read?
94
+ frames.each do |frame|
95
+ return true unless frame.path.start_with?(LIB_DIR)
96
+ return false unless PLUMBING.include?(frame.base_label)
97
+ end
98
+ false
99
+ end
100
+
101
+ # @return [String] ` at <path>:<line>` for the frame that made the read,
102
+ # or `""` when there is none outside the gem.
103
+ def site
104
+ frame = frames.find { !_1.path.start_with?(LIB_DIR) }
105
+ frame.nil? ? "" : " at #{frame.path}:#{frame.lineno}"
106
+ end
107
+
108
+ # The stack above this file, asked from wherever in it — dropping our own
109
+ # frames by path rather than by a `caller_locations` offset, which would
110
+ # be two different numbers and would drift on any refactor here.
111
+ # @return [Array<Object>] `Thread::Backtrace::Location`s.
112
+ def frames = caller_locations.drop_while { _1.path == __FILE__ }
113
+ end
114
+
115
+ # {Component#rect}, reporting first when it is about to answer the previous
116
+ # pass's rectangle.
117
+ # @return [Rect]
118
+ def rect
119
+ mode = Tuile.strict_layout
120
+ if mode && !Thread.current[REPORTING]
121
+ ancestor = stale_layout_ancestor
122
+ StrictLayout.report(self, ancestor, mode) unless ancestor.nil?
123
+ end
124
+ super
125
+ end
126
+ end
127
+ end
@@ -403,6 +403,37 @@ module Tuile
403
403
  raise TypeError, "cannot parse #{input.class}"
404
404
  end
405
405
  end
406
+
407
+ # Checks a glyph knob's value at assignment: exactly one grapheme
408
+ # cluster, one column wide. A component painting one glyph per cell
409
+ # spills a wider one onto its neighbour — silently, with nothing in the
410
+ # frame to point at — so every glyph knob asks here rather than at paint.
411
+ #
412
+ # def track_char=(char)
413
+ # @track_char = StyledString.validate_glyph(char, :track_char)
414
+ # end
415
+ #
416
+ # East-Asian-Ambiguous glyphs pass: Tuile measures them one column by
417
+ # construction (`D_ambiguous_width`), so no check can catch a terminal
418
+ # drawing them two.
419
+ # @param char [String]
420
+ # @param name [Symbol, String] the knob, for the message.
421
+ # @return [String] `char`, frozen.
422
+ # @raise [TypeError] when `char` is not a String.
423
+ # @raise [ArgumentError] when `char` is not exactly one grapheme cluster
424
+ # one column wide.
425
+ def validate_glyph(char, name)
426
+ raise TypeError, "#{name} must be a String, got #{char.inspect}" unless char.is_a?(String)
427
+
428
+ unless char.each_grapheme_cluster.take(2).size == 1
429
+ raise ArgumentError, "#{name} must be exactly one grapheme cluster, got #{char.inspect}"
430
+ end
431
+
432
+ width = plain(char).display_width
433
+ raise ArgumentError, "#{name} must be one column wide, got #{char.inspect} (#{width})" unless width == 1
434
+
435
+ -char
436
+ end
406
437
  end
407
438
 
408
439
  # The framework's single emoji-width policy, passed to every
@@ -500,27 +531,88 @@ module Tuile
500
531
  slice_spans(start, len)
501
532
  end
502
533
 
503
- # Truncates to a target column width, appending an ellipsis when
504
- # characters were dropped. The ellipsis counts toward the target — the
505
- # returned {StyledString}'s `display_width` never exceeds
506
- # `display_width`. When `self` already fits, `self` is returned. When
507
- # `display_width` is smaller than the ellipsis's own width, the ellipsis
508
- # is sliced down to fit and no original content is included.
534
+ # Truncates to a target column width, marking the cut end with an ellipsis.
535
+ # The ellipsis counts toward the target — the returned {StyledString}'s
536
+ # `display_width` never exceeds `display_width`, and comes out a column
537
+ # short when the cut lands mid-cluster, since a wide glyph straddling the
538
+ # boundary is dropped rather than halved. When `self` already fits, `self`
539
+ # is returned. When `display_width` is smaller than the ellipsis's own
540
+ # width, the ellipsis is sliced down to fit and no original content is
541
+ # included.
542
+ #
543
+ # `at: :start` keeps the *tail* instead, for text whose end identifies it
544
+ # and whose head is context — a path (`…/shared/markdown/`), a log line's
545
+ # message after its prefix.
509
546
  #
510
547
  # @param display_width [Integer] target column width.
511
- # @param ellipsis [String, StyledString] appended when truncation
548
+ # @param ellipsis [String, StyledString] added when truncation
512
549
  # occurs. Defaults to the Unicode horizontal-ellipsis `…` (one
513
550
  # column). A `String` is parsed via {.parse}, so ANSI in it is
514
551
  # preserved.
552
+ # @param at [Symbol] which end is cut: `:end` (the default) keeps the head
553
+ # and appends, `:start` keeps the tail and prepends.
554
+ # @raise [ArgumentError] unless `at` is `:start` or `:end`.
515
555
  # @return [StyledString]
516
- def ellipsize(display_width, ellipsis = "…")
556
+ def ellipsize(display_width, ellipsis = "…", at: :end)
557
+ raise ArgumentError, "expected :start or :end, got #{at.inspect}" unless %i[start end].include?(at)
517
558
  return self.class.new if display_width <= 0
518
559
  return self if self.display_width <= display_width
519
560
 
520
561
  ellipsis = self.class.parse(ellipsis)
521
562
  return ellipsis.slice(0, display_width) if ellipsis.display_width >= display_width
522
563
 
523
- slice(0, display_width - ellipsis.display_width) + ellipsis
564
+ keep = display_width - ellipsis.display_width
565
+ return ellipsis + slice(self.display_width - keep, keep) if at == :start
566
+
567
+ slice(0, keep) + ellipsis
568
+ end
569
+
570
+ # Pads on the right out to `width` display columns — {String#ljust}
571
+ # counted in columns, so a CJK glyph or an emoji measures what it paints.
572
+ #
573
+ # StyledString.plain("日本").ljust(6).to_s # => "日本 "
574
+ # row.ellipsize(w).ljust(w) # exactly w columns
575
+ #
576
+ # Never truncates: `self` comes back when already at least `width` wide,
577
+ # a negative gap included. The fill is unstyled, so it shows whatever
578
+ # background is painted under it.
579
+ # @param width [Integer] target display width.
580
+ # @param pad [String] the fill, one grapheme cluster one column wide.
581
+ # @return [StyledString]
582
+ # @raise [ArgumentError] when `pad` is not one cluster one column wide.
583
+ def ljust(width, pad = " ")
584
+ gap = gap_to(width, pad)
585
+ gap.zero? ? self : self + self.class.plain(pad * gap)
586
+ end
587
+
588
+ # Pads on the left out to `width` display columns; see {#ljust}.
589
+ #
590
+ # StyledString.plain("42").rjust(5).to_s # => " 42"
591
+ #
592
+ # @param width [Integer] target display width.
593
+ # @param pad [String] the fill, one grapheme cluster one column wide.
594
+ # @return [StyledString]
595
+ # @raise [ArgumentError] when `pad` is not one cluster one column wide.
596
+ def rjust(width, pad = " ")
597
+ gap = gap_to(width, pad)
598
+ gap.zero? ? self : self.class.plain(pad * gap) + self
599
+ end
600
+
601
+ # Pads both sides out to `width` display columns; see {#ljust}. An odd gap
602
+ # puts the extra column on the right, as {String#center} does.
603
+ #
604
+ # StyledString.plain("ab").center(5, "·").to_s # => "·ab··"
605
+ #
606
+ # @param width [Integer] target display width.
607
+ # @param pad [String] the fill, one grapheme cluster one column wide.
608
+ # @return [StyledString]
609
+ # @raise [ArgumentError] when `pad` is not one cluster one column wide.
610
+ def center(width, pad = " ")
611
+ gap = gap_to(width, pad)
612
+ return self if gap.zero?
613
+
614
+ left = gap / 2
615
+ self.class.plain(pad * left) + self + self.class.plain(pad * (gap - left))
524
616
  end
525
617
 
526
618
  # Splits on `"\n"`, preserving spans on each side. A trailing newline
@@ -646,6 +738,34 @@ module Tuile
646
738
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
647
739
  end
648
740
 
741
+ # Returns a copy with `fg` set **only on spans that have none**; a span with
742
+ # an explicit fg is left untouched. The foreground counterpart of
743
+ # {#under_bg} — it gives a caption a default tone while keeping the parts
744
+ # the app colored itself:
745
+ #
746
+ # caption = StyledString.plain("Queue ") + StyledString.styled("3", fg: theme[:notice])
747
+ # caption.under_fg(theme[:hint]) # "Queue " in :hint, "3" stays :notice
748
+ #
749
+ # An `inverse` span is skipped even when its `fg` member is nil, as
750
+ # {#under_bg} skips it: SGR 7 swaps the pair, so a filled fg would become
751
+ # the span's *background*.
752
+ #
753
+ # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground color,
754
+ # coerced via {Color.coerce}. `nil` returns `self` unchanged.
755
+ # @return [StyledString]
756
+ def under_fg(fg)
757
+ return self if fg.nil?
758
+
759
+ fg = Color.coerce(fg)
760
+ self.class.new(@spans.map do |span|
761
+ if span.style.fg.nil? && !span.style.inverse
762
+ Span.new(text: span.text, style: span.style.merge(fg: fg))
763
+ else
764
+ span
765
+ end
766
+ end)
767
+ end
768
+
649
769
  # Returns a new {StyledString} with `bold` applied to every span, preserving
650
770
  # each span's text and other style attributes (`fg`, `bg`, `italic`,
651
771
  # `underline`, `strikethrough`). The bold-attribute counterpart of
@@ -740,6 +860,16 @@ module Tuile
740
860
  result
741
861
  end
742
862
 
863
+ # @param width [Integer]
864
+ # @param pad [String]
865
+ # @return [Integer] columns {#ljust} and friends must fill, never negative.
866
+ # @raise [ArgumentError] when `pad` is not one cluster one column wide.
867
+ def gap_to(width, pad)
868
+ # The default skips validation: the fill runs once per painted row.
869
+ self.class.validate_glyph(pad, :pad) unless pad == " "
870
+ (width - display_width).clamp(0, nil)
871
+ end
872
+
743
873
  # @param start_or_range [Integer, Range]
744
874
  # @param len [Integer, nil]
745
875
  # @param total [Integer] receiver's full display width.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ module Testing
5
+ # Receiver syntax for {Tuile::Testing}'s gestures, so a located component
6
+ # is driven in one line instead of passed back as an argument:
7
+ #
8
+ # using Tuile::Testing::Gestures
9
+ #
10
+ # Testing.get(Component::TextField, id: :name)._value = "Zaphod"
11
+ # Testing.get(Component::Button) { _1.caption.to_s == "Save" }._click
12
+ #
13
+ # A **refinement**, so it exists only where `using` says so — per file or
14
+ # per `describe` block, never leaking to a sibling — and nothing lands on
15
+ # {Component} itself. That is `D_component_lookup`'s standing answer for
16
+ # wanting receiver syntax.
17
+ #
18
+ # **The leading underscore is the mark**, borrowed from Karibu-Testing: it
19
+ # says *testing API, not the component's own*, which matters most for
20
+ # `_value=` sitting one character from a real `value=`. The unrefined
21
+ # spellings keep plain names, since `Testing.` already marks them.
22
+ module Gestures
23
+ refine Component do
24
+ # @return [void] see {Testing.click}.
25
+ def _click = Testing.click(self)
26
+
27
+ # @param value [Object] see {Testing.set_value}.
28
+ # @return [void]
29
+ def _value=(value)
30
+ Testing.set_value(self, value)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end