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
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # One component's background: what it states, and the {Color} that resolves
5
+ # to right now. A component reaches its own through the protected
6
+ # {Component#bg}; an app tints through {Component#bg_color=}.
7
+ #
8
+ # # a widget: its own well, brighter while focused
9
+ # def initialize
10
+ # super
11
+ # bg.default_color = ComponentBackground::INPUT_WELL
12
+ # end
13
+ #
14
+ # panel.bg_color = Theme.ref(:panel_bg) # an app: tint a whole subtree
15
+ #
16
+ # The chain, first answer wins: the owner's {Component#error_bg_color}, then
17
+ # {#color} (the app's), then {#default_color} (the widget's), then the parent's
18
+ # {#effective}, then `nil` — the terminal default. {INHERIT} at a level skips
19
+ # the owner's remaining levels and goes straight to the parent. Every level
20
+ # takes a {Color}, a {Theme::Ref} or a Hash keyed by {STATES}, and resolves
21
+ # against the live theme and the owner's {Component#active?} at paint time,
22
+ # so nothing here caches a color.
23
+ #
24
+ # == Implementation details
25
+ #
26
+ # The error level is *pulled* from the owner rather than stored here: it
27
+ # follows the validation state, and a pushed copy would need every edge of
28
+ # that state to remember to re-set it. A stale error well fails silently.
29
+ class ComponentBackground
30
+ # The states a background may be keyed by. Closed and framework-defined:
31
+ # a key is added when Tuile grows the state, never to let an app invent one.
32
+ # @return [Array<Symbol>]
33
+ STATES = %i[normal active].freeze
34
+
35
+ # Assign to {#color} to say "I contribute no background of my own" —
36
+ # resolution skips the owner's {#default_color} and takes whatever
37
+ # surrounds it. CSS's `background: inherit`, and the reason a widget with a
38
+ # well can be made to sit flush in a tinted panel:
39
+ #
40
+ # field.bg_color = ComponentBackground::INHERIT # no well; take the pane's tint
41
+ #
42
+ # Distinct from `nil`, which falls through to {#default_color} *first*.
43
+ # There is deliberately no counterpart forcing the terminal default despite
44
+ # a tinted ancestor (`D_bg_inherit`).
45
+ # @return [Symbol]
46
+ INHERIT = :inherit
47
+
48
+ # The well every input field paints: {Theme#input_bg_color} at rest,
49
+ # {Theme#active_bg_color} while on the focus chain. Live {Theme::Ref}s, so a
50
+ # {Screen#theme=} restyles it with no hook.
51
+ # @return [Hash{Symbol => Theme::Ref}]
52
+ INPUT_WELL = { normal: Theme.ref(:input_bg_color), active: Theme.ref(:active_bg_color) }.freeze
53
+
54
+ # @param owner [Component] whose background this is.
55
+ def initialize(owner)
56
+ @owner = owner
57
+ @color = nil
58
+ @default_color = nil
59
+ end
60
+
61
+ # @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, Symbol, nil]
62
+ # the app's background — the value as set, so a {Theme::Ref} comes back
63
+ # unresolved and a state map comes back a Hash; `nil` when unset.
64
+ attr_reader :color
65
+
66
+ # Tints the owner and every descendant that doesn't state its own, and
67
+ # invalidates that subtree. See {Component#bg_color=}, its public face.
68
+ # @param value [Color, Theme::Ref, Hash, Symbol, Integer, Array<Integer>, nil]
69
+ # @raise [ArgumentError] when a Hash carries a key outside {STATES}.
70
+ # @raise [KeyError] when a {Theme::Ref} names an absent custom token.
71
+ # @return [void]
72
+ def color=(value)
73
+ value = coerce(value)
74
+ return if @color == value
75
+
76
+ @color = value
77
+ invalidate
78
+ end
79
+
80
+ # @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, nil] the
81
+ # widget's own surface — `nil` by default, meaning "whatever is behind me
82
+ # shows through".
83
+ attr_reader :default_color
84
+
85
+ # States the opaque surface a widget paints when the app has set no
86
+ # {#color} — and inheritance stops there, which is what keeps a form's
87
+ # fields looking like fields inside a tinted panel. Set it unconditionally,
88
+ # at construction: a widget owned by a bigger one is told so with
89
+ # {INHERIT}, and must not work it out from where it sits in the tree.
90
+ #
91
+ # bg.default_color = ComponentBackground::INPUT_WELL # the field well
92
+ # bg.default_color = { normal: Theme.ref(:panel_bg), active: … } # an app widget's
93
+ #
94
+ # **Hand it a {Theme::Ref}, never `screen.theme.input_bg_color`** — a
95
+ # resolved {Color} is a cached token and strands on the old scheme after a
96
+ # {Screen#theme=}, with nothing raising.
97
+ # @param value [Color, Theme::Ref, Hash, Symbol, Integer, Array<Integer>, nil]
98
+ # @raise [ArgumentError] when a Hash carries a key outside {STATES}.
99
+ # @raise [KeyError] when a {Theme::Ref} names an absent custom token.
100
+ # @return [void]
101
+ def default_color=(value)
102
+ value = coerce(value)
103
+ return if @default_color == value
104
+
105
+ @default_color = value
106
+ invalidate
107
+ end
108
+
109
+ # @return [Color, nil] the background actually painted, for the state the
110
+ # owner is in right now — the whole chain, resolved. {Screen#canvas_for}
111
+ # loads it onto the canvas; an app never needs it.
112
+ def effective
113
+ own = resolve(@owner.__send__(:error_bg_color)) || resolve(@color) || resolve(@default_color)
114
+ return parent_effective if own.nil? || own == INHERIT
115
+
116
+ own
117
+ end
118
+
119
+ # What surrounds the owner — the app's {#color}, else whatever the parent
120
+ # paints. Skips {#default_color} and the error well, the owner's *own*
121
+ # surface, which is what makes it the right answer for a dead tail outside
122
+ # {Component#extent} and a container's gaps.
123
+ # @return [Color, nil]
124
+ def ambient
125
+ own = resolve(@color)
126
+ return parent_effective if own.nil? || own == INHERIT
127
+
128
+ own
129
+ end
130
+
131
+ private
132
+
133
+ # @return [Color, nil]
134
+ def parent_effective = @owner.parent&.__send__(:bg)&.effective
135
+
136
+ # @return [void]
137
+ def invalidate
138
+ @owner.walk_tree { |c| @owner.screen.invalidate(c) } if @owner.attached?
139
+ end
140
+
141
+ # Collapses one level to the {Color} it means right now. An absent state
142
+ # key yields `nil`, so resolution falls through to the next level — which
143
+ # is what lets `bg_color = { active: … }` keep the widget's own normal well.
144
+ # @param value [Color, Theme::Ref, Hash, Symbol, nil]
145
+ # @return [Color, Symbol, nil]
146
+ def resolve(value)
147
+ case value
148
+ when nil then nil
149
+ when Hash then resolve(value[@owner.active? ? :active : :normal])
150
+ when Theme::Ref then value.resolve(@owner.screen.theme)
151
+ else value
152
+ end
153
+ end
154
+
155
+ # Validates and normalizes a level's value, so a bad token or a misspelled
156
+ # state raises at the assignment rather than deep in a repaint. A chrome
157
+ # token needs no theme to check, which keeps construction screen-free.
158
+ # @param value [Object]
159
+ # @return [Color, Theme::Ref, Hash, Symbol, nil]
160
+ # @raise [ArgumentError] on a Hash key outside {STATES}.
161
+ # @raise [KeyError] on a {Theme::Ref} naming an absent custom token.
162
+ def coerce(value)
163
+ case value
164
+ when nil, Color, INHERIT then value
165
+ when Theme::Ref
166
+ value.tap { _1.resolve(@owner.screen.theme) unless Theme.chrome_token?(_1.name) }
167
+ when Hash
168
+ unknown = value.keys - STATES
169
+ raise ArgumentError, "unknown background state(s) #{unknown.join(", ")}; known: #{STATES.join(", ")}" \
170
+ unless unknown.empty?
171
+
172
+ value.to_h { |state, color| [state, coerce(color)] }.freeze
173
+ else Color.coerce(value)
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Pure queries over the component tree, as module functions:
5
+ #
6
+ # ComponentUtil.effectively_visible?(field) # false under a hidden panel
7
+ #
8
+ # Home for a question that belongs to no one component and to no one asker.
9
+ # Deliberately **not** {Component} methods, for the reason `D_empty_ancestor`
10
+ # declined a `Component#paintable?`: each reads as a component-level concept
11
+ # and is really the framework's question.
12
+ #
13
+ # **The gate for a new member** is a pure query over the tree — no state, no
14
+ # mutation — with **two or more call sites in `lib/`**; one caller stays
15
+ # private to the class that asks. Without it this becomes the drawer internal
16
+ # things go in.
17
+ #
18
+ # Tuile-internal: a member may change or vanish with no migration note, and
19
+ # one an app turns out to need graduates to a documented home instead.
20
+ #
21
+ # @api private
22
+ module ComponentUtil
23
+ module_function
24
+
25
+ # Whether `component` is genuinely on screen: attached, with neither it nor
26
+ # any ancestor hidden. {Component#visible?} is a component's own flag alone,
27
+ # so a field under a hidden panel is still `visible?` itself.
28
+ #
29
+ # A *walk* needs no such predicate — it prunes at the hidden subtree's root
30
+ # ({Component#walk_shown_tree}).
31
+ # @param component [Component]
32
+ # @raise [TypeError] on `nil` — a caller that may hold one says so itself,
33
+ # rather than having "no component" quietly answer "not visible".
34
+ # @return [Boolean]
35
+ def effectively_visible?(component)
36
+ raise TypeError, "expected Component, got nil" if component.nil?
37
+
38
+ cursor = component
39
+ cursor = cursor.parent while cursor&.visible?
40
+ cursor.nil? && component.attached?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The marker every event includes: *something happened, described by a frozen
5
+ # value*.
6
+ #
7
+ # class ValueChangeEvent < Data.define(:source, :value)
8
+ # include Tuile::Event
9
+ # end
10
+ #
11
+ # case event
12
+ # when Mouse::DownEvent then … # one concrete class
13
+ # when Tuile::Event then … # …or all of them at once
14
+ # end
15
+ #
16
+ # One concept in three namespaces — {Mouse}'s wire events, {EventQueue}'s loop
17
+ # events, and the ones a {Listeners} slot fires. There is no base class: the
18
+ # `case` above works off the marker alone (`D_mouse_dispatch`).
19
+ #
20
+ # **It mandates no members and supplies no defaults.** `source` belongs to the
21
+ # classes that have one — for the twelve events predating this marker it would
22
+ # be nil throughout, and a member nothing reads is the mailbox shape
23
+ # `D_bad_input` refuses. A default here would be worse than the absence rather
24
+ # than better: `from_user? = false` on the marker would make
25
+ # {Mouse::DownEvent}, the most from-user thing in the gem, answer `false`, and
26
+ # nothing would say so.
27
+ module Event
28
+ end
29
+ end
@@ -32,6 +32,9 @@ module Tuile
32
32
  # Submits block to be run in the event queue. Returns immediately.
33
33
  #
34
34
  # The function may be called from any thread.
35
+ #
36
+ # The block runs only while a loop is draining: submitted before the first
37
+ # {#run_loop} it waits for one, submitted after the last it never runs.
35
38
  # @yield called from the event-loop thread.
36
39
  # @yieldreturn [void]
37
40
  # @return [void]
@@ -165,6 +168,7 @@ module Tuile
165
168
  # @!attribute [r] key
166
169
  # @return [String] key code.
167
170
  class KeyEvent < Data.define(:key)
171
+ include Tuile::Event
168
172
  end
169
173
 
170
174
  # Text arrived from the clipboard rather than the keyboard: the terminal
@@ -181,6 +185,8 @@ module Tuile
181
185
  # @return [String] the pasted text, `\n`-normalized by
182
186
  # {Keys.normalize_paste}.
183
187
  class PasteEvent < Data.define(:text)
188
+ include Tuile::Event
189
+
184
190
  # @param text [String]
185
191
  def initialize(text:)
186
192
  super(text: text.freeze)
@@ -193,6 +199,7 @@ module Tuile
193
199
  # @!attribute [r] error
194
200
  # @return [StandardError] the underlying error.
195
201
  class ErrorEvent < Data.define(:error)
202
+ include Tuile::Event
196
203
  end
197
204
 
198
205
  # TTY has been resized. Contains the current width and height of the TTY
@@ -203,6 +210,8 @@ module Tuile
203
210
  # @!attribute [r] height
204
211
  # @return [Integer] terminal height in rows.
205
212
  class TTYSizeEvent < Data.define(:width, :height)
213
+ include Tuile::Event
214
+
206
215
  # @param width [Integer]
207
216
  # @param height [Integer]
208
217
  def initialize(width:, height:)
@@ -233,6 +242,8 @@ module Tuile
233
242
  # @!attribute [r] scheme
234
243
  # @return [Symbol] `:light` or `:dark`.
235
244
  class ColorSchemeEvent < Data.define(:scheme)
245
+ include Tuile::Event
246
+
236
247
  # The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
237
248
  # light.
238
249
  # @return [Regexp]
@@ -257,6 +268,8 @@ module Tuile
257
268
  # @!attribute [r] color
258
269
  # @return [Color] the reported background, 24-bit RGB.
259
270
  class BackgroundColorEvent < Data.define(:color)
271
+ include Tuile::Event
272
+
260
273
  # @param key [String] key read via {Keys.getkey}.
261
274
  # @return [BackgroundColorEvent, nil] nil when `key` is not an OSC 11
262
275
  # background reply.
@@ -271,6 +284,7 @@ module Tuile
271
284
  # repainting windows.
272
285
  class EmptyQueueEvent
273
286
  include Singleton
287
+ include Tuile::Event
274
288
  end
275
289
 
276
290
  # Handle returned by {EventQueue#tick}. Cancel a running ticker via
@@ -11,6 +11,11 @@ module Tuile
11
11
  # run an event loop, so it is *not* suitable for system-testing whole apps
12
12
  # — for that, drive the real script through a PTY (see `spec/examples/`).
13
13
  #
14
+ # It also turns the stale-rect diagnostic on ({Tuile::StrictLayout}): a rect
15
+ # read before the layout has settled raises here, where in an app it would
16
+ # quietly answer the previous pass's rectangle. `Tuile.strict_layout = false`
17
+ # opts a suite out; {Tuile.without_strict_layout} opts one read out.
18
+ #
14
19
  # Call {Screen.fake} to initialize the fake screen easily. Typical usage:
15
20
  #
16
21
  # before { Screen.fake }
@@ -23,13 +28,21 @@ module Tuile
23
28
  # assert_includes Screen.instance.prints.join, "hi"
24
29
  # end
25
30
  class FakeScreen < Screen
26
- def initialize
27
- super
31
+ # The terminal starts at the size given, as if it had always been that big:
32
+ # nothing is dispatched and nothing repaints. To exercise a change of size,
33
+ # the SIGWINCH path, call {#resize_terminal} instead.
34
+ # @param width [Integer] the terminal's columns.
35
+ # @param height [Integer] the terminal's rows.
36
+ def initialize(width: 160, height: 50)
37
+ super()
38
+ # `Tuile.strict_layout` defaults to `:raise` under this screen; this puts
39
+ # the check it answers through into `Component#rect`.
40
+ StrictLayout.install
28
41
  @event_queue = FakeEventQueue.new
29
- @size = Size.new(160, 50)
42
+ @size = Size.new(width, height)
30
43
  # super sized both to the test runner's TTY.
31
44
  @buffer.resize(@size)
32
- @pane.rect = Rect.new(0, 0, @size.width, @size.height)
45
+ size_pane
33
46
  @prints = []
34
47
  end
35
48
 
@@ -72,7 +85,7 @@ module Tuile
72
85
  # against `\n`.
73
86
  # @param text [String]
74
87
  # @return [Boolean] true if some component consumed it.
75
- def paste(text) = handle_paste(Keys.normalize_paste(text))
88
+ def paste(text) = dispatch(EventQueue::PasteEvent.new(Keys.normalize_paste(text)))
76
89
 
77
90
  # @param component [Component] the component to check.
78
91
  # @return [Boolean]
@@ -101,7 +114,7 @@ module Tuile
101
114
  # Plays a whole click at a screen cell — the press, then the release that
102
115
  # ends its grab:
103
116
  #
104
- # screen.click(save_button.rect.left, save_button.rect.top)
117
+ # screen.click(save_button.absolute_rect.left, save_button.absolute_rect.top)
105
118
  #
106
119
  # Routed exactly as the terminal's own report would be ({Mouse::Router}), so
107
120
  # it focuses, dismisses popups and bubbles.
@@ -114,26 +127,33 @@ module Tuile
114
127
  release(x, y)
115
128
  end
116
129
 
130
+ # The terminal reporting a new size, routed as its own report would be: the
131
+ # pane takes the whole screen, and every popup is placed again.
132
+ # @param width [Integer]
133
+ # @param height [Integer]
134
+ # @return [void]
135
+ def resize_terminal(width, height) = dispatch(EventQueue::TTYSizeEvent.new(width, height))
136
+
117
137
  # Half a {#click}, for a spec about the grab — what is claimed, what the
118
138
  # drag does, what the release lands on.
119
139
  # @param x [Integer] 0-based column.
120
140
  # @param y [Integer] 0-based row.
121
141
  # @param button [Symbol] `:left`, `:middle` or `:right`.
122
142
  # @return [void]
123
- def press(x, y, button: :left) = handle_mouse(Mouse::DownEvent.new(button, x, y))
143
+ def press(x, y, button: :left) = dispatch(Mouse::DownEvent.new(button, x, y))
124
144
 
125
145
  # The other half of {#press}.
126
146
  # @param x [Integer] 0-based column.
127
147
  # @param y [Integer] 0-based row.
128
148
  # @return [void]
129
- def release(x, y) = handle_mouse(Mouse::UpEvent.new(x, y))
149
+ def release(x, y) = dispatch(Mouse::UpEvent.new(x, y))
130
150
 
131
151
  # One wheel notch over a cell.
132
152
  # @param direction [Symbol] `:up`, `:down`, `:left` or `:right`.
133
153
  # @param x [Integer] 0-based column.
134
154
  # @param y [Integer] 0-based row.
135
155
  # @return [void]
136
- def scroll(direction, x, y) = handle_mouse(Mouse::ScrollEvent.new(direction, x, y))
156
+ def scroll(direction, x, y) = dispatch(Mouse::ScrollEvent.new(direction, x, y))
137
157
 
138
158
  # Moves the pointer, firing the enter/exit hooks the new position implies —
139
159
  # or, while a press is grabbed, one {Component#handle_mouse_drag}.
@@ -141,7 +161,7 @@ module Tuile
141
161
  # @param y [Integer] 0-based row.
142
162
  # @param button [Symbol, nil] the button held while moving, if any.
143
163
  # @return [void]
144
- def move(x, y, button: nil) = handle_mouse(Mouse::MoveEvent.new(button, x, y))
164
+ def move(x, y, button: nil) = dispatch(Mouse::MoveEvent.new(button, x, y))
145
165
 
146
166
  # Plays a whole drag: the press at the first point, one move per point
147
167
  # after it, and the release at the last.
@@ -169,6 +189,17 @@ module Tuile
169
189
 
170
190
  private
171
191
 
192
+ # Settles the layout on the way *in* as well as out. A spec mutates between
193
+ # gestures, where the loop would have settled at the end of the previous
194
+ # dispatch and there is none — and routing a press reads rects, so without
195
+ # this a `click` hit-tests what the last mutation left half-finished.
196
+ # @param event [Object] see {Screen#dispatch}.
197
+ # @return [Object] whatever the handler returned.
198
+ def dispatch(event)
199
+ flush_layout
200
+ super
201
+ end
202
+
172
203
  # @param point [Point, Array(Integer, Integer)]
173
204
  # @return [Point]
174
205
  def coerce_point(point)
data/lib/tuile/keys.rb CHANGED
@@ -158,12 +158,13 @@ module Tuile
158
158
 
159
159
  # Escape sequence. Try to read more data.
160
160
  begin
161
- # Read up to 5 bytes: that's the maximum tail length of any escape
162
- # sequence Tuile recognizes after the initial \e (X10 mouse `[Mbxy`,
163
- # CTRL+arrow `[1;5D`, etc.). Reading 6 here would over-read into the
164
- # next sequence on tight mouse-event bursts — we'd silently steal
165
- # the next event's leading \e and the rest of it would surface as
166
- # individual printable keypresses in focused inputs.
161
+ # Read up to 5 bytes: that's the maximum tail length of any *fixed*-
162
+ # length escape sequence Tuile recognizes after the initial \e (X10
163
+ # mouse `[Mbxy`, CTRL+arrow `[1;5D`, etc.); the variable-length ones
164
+ # are drained below. Reading 6 here would over-read into the next
165
+ # sequence on tight mouse-event bursts — we'd silently steal the next
166
+ # event's leading \e and the rest of it would surface as individual
167
+ # printable keypresses in focused inputs.
167
168
  char += $stdin.read_nonblock(5)
168
169
  rescue IO::EAGAINWaitReadable
169
170
  # The "ESC" key pressed => only the \e char is emitted.
@@ -176,6 +177,14 @@ module Tuile
176
177
  # instead of leaking tail bytes as keypresses.
177
178
  char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
178
179
 
180
+ # SGR mouse reports (`\e[<Cb;x;yM`, mode 1006) are variable-length and do
181
+ # not align to read boundaries, so no gulp width fits: drain to the final
182
+ # `M`/`m` a byte at a time. The gulp above cannot over-read one — the
183
+ # shortest report is 8 bytes after the `\e` — and only digits and `;`
184
+ # precede the terminator, so this stops at the first event's end.
185
+ # Keyboard sequences never start with `\e[<`, so this eats no real key.
186
+ char += $stdin.read(1) while char.start_with?("\e[<") && !char.end_with?("M", "m")
187
+
179
188
  # Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
180
189
  # can outgrow the 5-byte gulp above — the mode-2031 color-scheme
181
190
  # notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Who may assign a rect right now. {Component#rect=} is refused outside the
5
+ # parent's {Component#relayout}, and this is the bracket that says when one
6
+ # is running:
7
+ #
8
+ # LayoutPass.run(container) { relayout } # inside the container
9
+ #
10
+ # A pass also records which children it has placed *so far*. Before the
11
+ # first, {Component#invalidate_layout} drops a mark the container makes on
12
+ # itself — the pass that would answer it is the one running — and until a
13
+ # child's turn, {Component#rect_stale?} reports its rect as the previous
14
+ # pass's ({.unplaced?}).
15
+ #
16
+ # **Thread-local rather than an ivar**, because a tree with no {Screen}
17
+ # places children too, so there is no one object to hang the state on.
18
+ #
19
+ # Tuile-internal: a member may change or vanish with no migration note. The
20
+ # rule it enforces is {Component#rect=}'s and is documented there.
21
+ #
22
+ # @api private
23
+ module LayoutPass
24
+ # The thread-local naming what is placing children right now.
25
+ # @return [Symbol]
26
+ PLACING = :tuile_placing
27
+ private_constant :PLACING
28
+
29
+ # The thread-local holding the children the current placer has assigned a
30
+ # rect so far — an identity `Set`, or `nil` before the first.
31
+ # @return [Symbol]
32
+ PLACED = :tuile_placed
33
+ private_constant :PLACED
34
+
35
+ # Drain rounds a {Screen#flush_layout} or a detached {Component#flush_layout}
36
+ # runs before giving up. No container sizes itself from its children, so a
37
+ # tree settles in about its depth; one that is still marking past this is
38
+ # feeding a pass's output back into its input — a {Component::Scroller}
39
+ # whose `content_rows` the app derives from the content's width, say — and
40
+ # would otherwise hang the UI thread.
41
+ # @return [Integer]
42
+ MAX_ROUNDS = 50
43
+
44
+ module_function
45
+
46
+ # Runs the block with `placer` as the one thing whose children's rects may
47
+ # be assigned. Nests: the enclosing pass is restored on the way out.
48
+ # @param placer [Component, Screen]
49
+ # @return [Object] the block's value.
50
+ def run(placer)
51
+ outer = Thread.current[PLACING]
52
+ outer_placed = Thread.current[PLACED]
53
+ Thread.current[PLACING] = placer
54
+ Thread.current[PLACED] = nil
55
+ yield
56
+ ensure
57
+ Thread.current[PLACING] = outer
58
+ Thread.current[PLACED] = outer_placed
59
+ end
60
+
61
+ # Whether any pass is placing children right now — what a drain refuses to
62
+ # run inside ({.refuse_nested}), and {Screen#focused=} defers its geometry under.
63
+ # @return [Boolean]
64
+ def running? = !Thread.current[PLACING].nil?
65
+
66
+ # The guard at the top of both drains, {Screen#flush_layout} and a detached
67
+ # {Component#flush_layout}.
68
+ # @raise [Tuile::Error] while a pass is running: it has not placed its
69
+ # children yet, so a nested drain would read the rects it is about to
70
+ # reassign.
71
+ # @return [void]
72
+ def refuse_nested
73
+ return unless running?
74
+
75
+ raise Tuile::Error, "flush_layout inside #{Thread.current[PLACING]}'s relayout: the running pass " \
76
+ "has not placed its children yet, so a nested drain would read stale rects"
77
+ end
78
+
79
+ # What may assign `component`'s rect: its parent, whose
80
+ # {Component#relayout} does. The exception is the {ScreenPane}, which has
81
+ # no parent whose pass could place it, so the {Screen} does (`D_tree_first`
82
+ # keeps the screen out of the tree). Those are the only two cases — this is
83
+ # a closed rule rather than a hook a component may answer for itself.
84
+ # @param component [Component]
85
+ # @return [Component, Screen, nil]
86
+ def placer(component)
87
+ component.is_a?(ScreenPane) ? component.screen : component.parent
88
+ end
89
+
90
+ # @param component [Component] the one whose rect is being assigned.
91
+ # @raise [Tuile::Error] unless {.placer} is what is placing now.
92
+ # @return [void]
93
+ def check(component)
94
+ current = Thread.current[PLACING]
95
+ return if !current.nil? && current.equal?(placer(component))
96
+
97
+ if component.parent.nil?
98
+ raise Tuile::Error, "#{component} has no parent to place it; to size a detached tree, " \
99
+ "hold it in a Layout::Absolute (add(tree, rect), then flush_layout)"
100
+ end
101
+ raise Tuile::Error, "#{component}'s rect assigned outside #{component.parent}'s relayout; change " \
102
+ "what the parent places it by instead (Absolute#constrain, Box#constrain, " \
103
+ "Overlay#placement=)"
104
+ end
105
+
106
+ # Runs every {Component#relayout} `root`'s tree owes, to a fixpoint — the one
107
+ # drain behind both {Screen#flush_layout} and a detached
108
+ # {Component#flush_layout}.
109
+ #
110
+ # **The dirty flags are the queue.** Each round walks the tree pre-order and
111
+ # runs whatever is marked, so a parent lays out before the children whose
112
+ # rects it just wrote, and those children run in the same walk. A mark on
113
+ # something *earlier* in the order — a child's pass marking its parent —
114
+ # waits for the next round, and the drain ends on the first walk that finds
115
+ # nothing marked. A flag is cleared by the pass that answers it, so a
116
+ # container is never run twice for one mark, and a raise strands nothing.
117
+ # @param root [Component] the tree's root: the {ScreenPane}, or a detached root.
118
+ # @param rounds [Integer] rounds already run in this flush, so a caller
119
+ # draining more than once — the screen, re-checking anchors — keeps one count.
120
+ # @raise [Tuile::Error] past {MAX_ROUNDS} rounds, naming the containers still marked.
121
+ # @return [Integer] the rounds run in total.
122
+ def drain(root, rounds = 0)
123
+ while drain_round(root)
124
+ next rounds += 1 if rounds < MAX_ROUNDS
125
+
126
+ culprits = []
127
+ root.walk_tree { culprits << _1 if _1.layout_dirty? }
128
+ raise Tuile::Error, "layout did not settle after #{MAX_ROUNDS} rounds, still marking: " \
129
+ "#{culprits.first(5).map(&:inspect).join(", ")} — a relayout keeps changing its " \
130
+ "own input (content_rows derived from the content's width?)"
131
+ end
132
+ rounds
133
+ end
134
+
135
+ # One pre-order walk running every marked container.
136
+ # @param root [Component]
137
+ # @return [Boolean] whether it ran anything.
138
+ def drain_round(root)
139
+ ran = false
140
+ root.walk_tree do |component|
141
+ next unless component.layout_dirty?
142
+
143
+ # `__send__`: private, because it is this drain's alone.
144
+ component.__send__(:perform_relayout)
145
+ ran = true
146
+ end
147
+ ran
148
+ end
149
+ private_class_method :drain_round
150
+
151
+ # Records that the running pass has assigned `component` its rect. Past
152
+ # the first, a mark on the placer is kept rather than dropped.
153
+ # @param component [Component]
154
+ # @return [void]
155
+ def note_placement(component)
156
+ (Thread.current[PLACED] ||= Set.new.compare_by_identity) << component
157
+ end
158
+
159
+ # Whether `component` is the running placer and has placed no child yet —
160
+ # the window in which a mark it makes on itself is redundant.
161
+ # @param component [Component]
162
+ # @return [Boolean]
163
+ def before_first_placement?(component)
164
+ Thread.current[PLACING].equal?(component) && Thread.current[PLACED].nil?
165
+ end
166
+
167
+ # Whether `component`'s placer is running its pass right now and has not
168
+ # reached it yet — so its rect is the previous pass's, though no flag says
169
+ # so: {Component#perform_relayout} clears the placer's before the body runs.
170
+ # @param component [Component]
171
+ # @return [Boolean]
172
+ def unplaced?(component)
173
+ placing = Thread.current[PLACING]
174
+ return false if placing.nil? || !placing.equal?(placer(component))
175
+
176
+ placed = Thread.current[PLACED]
177
+ placed.nil? || !placed.include?(component)
178
+ end
179
+ end
180
+ end