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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A listener slot: the callables registered on one `on_foo`, fired with one
5
+ # {Event}. The reader *is* the registrar:
6
+ #
7
+ # button.on_click { save } # a block
8
+ # field.on_value_change << method(:preview) # anything callable
9
+ # field.on_value_change.remove(method(:preview)) # …removed, holding nothing
10
+ # field.on_value_change.empty? # => true
11
+ #
12
+ # `Method#==` compares receiver and name, so a widget unsubscribes with the
13
+ # expression it subscribed with and holds nothing. A `Proc` equals only itself,
14
+ # which is why the block form returns the `Proc` it registered rather than the
15
+ # list.
16
+ #
17
+ # Declare one with {Declare}, never by hand. It is not a collection: {#each},
18
+ # {#size} and {#include?} are the whole surface.
19
+ #
20
+ # == There is no setter, and no `clear`
21
+ #
22
+ # The semantics are *append, and remove your own*, and the absent `on_foo=` is
23
+ # the point. A replaceable slot made every claim a contention: wherever the
24
+ # gem wires a listener onto a child it also exposes for tuning
25
+ # (`DateTimeField#date_field`, `RadioGroup#list`, `TabSheet#strip`), an app
26
+ # reaching for that slot silently broke the widget. With no replace operation
27
+ # that failure cannot be written.
28
+ #
29
+ # == An empty list is meaningful, and each slot's rdoc says what its empty means
30
+ #
31
+ # Nothing here reads empty as "nothing to do": while empty, a key-claiming slot
32
+ # declines the key so it keeps bubbling and {Screen#on_error} re-raises.
33
+ # {Declare}'s transition block is for the widget that must *install* something
34
+ # when the slot stops being empty.
35
+ #
36
+ # Duplicates are allowed: two adds fire twice, and one {#remove} balances one
37
+ # {#add}.
38
+ class Listeners
39
+ # A registered callable plus the arity verdict {#fire} needs, settled once
40
+ # at {#add} rather than per fire.
41
+ Entry = Data.define(:callable, :takes_event)
42
+ private_constant :Entry
43
+
44
+ # @param name [Symbol] the slot's name (`:on_click`), used in error messages.
45
+ # @yieldparam claimed [Boolean] `true` when the list just became non-empty,
46
+ # `false` when it just became empty — never called for any other change.
47
+ def initialize(name:, &claim_changed)
48
+ @name = name
49
+ @claim_changed = claim_changed
50
+ @entries = []
51
+ end
52
+
53
+ # Appends `callable` and returns it, so a lambda can be held for removal.
54
+ #
55
+ # cb = field.on_value_change.add(->(e) { preview(e.value) })
56
+ # field.on_value_change.remove(cb)
57
+ #
58
+ # Arity is settled here rather than per fire, so a listener that cannot take
59
+ # the event raises at registration instead of later inside a repaint on the
60
+ # loop thread.
61
+ #
62
+ # Deliberately does not call `Screen#check_locked`, alone among the gem's
63
+ # mutations: that would mean holding an owner, hence a `Screen` reach inside
64
+ # {Component::HasValue} and {Component::HasValidation}, plain mixins with
65
+ # none.
66
+ #
67
+ # @param callable [#call] the listener.
68
+ # @return [#call] `callable`.
69
+ # @raise [ArgumentError] if it is not callable, or cannot take zero or one
70
+ # argument.
71
+ def add(callable)
72
+ entry = Entry.new(callable, takes_event?(callable))
73
+ was_empty = @entries.empty?
74
+ @entries << entry
75
+ @claim_changed&.call(true) if was_empty
76
+ callable
77
+ end
78
+
79
+ # Appends `callable` and returns self, so registrations chain.
80
+ #
81
+ # field.on_value_change << method(:preview) << method(:log)
82
+ #
83
+ # @param callable [#call] the listener.
84
+ # @return [self]
85
+ def <<(callable)
86
+ add(callable)
87
+ self
88
+ end
89
+
90
+ # Removes the **first** occurrence of `callable`.
91
+ #
92
+ # Not `Array#delete`, which drops every occurrence: one `remove` balances
93
+ # one {#add}, the only rule that composes when a widget and an app happen to
94
+ # register the same `method(:x)`.
95
+ #
96
+ # @param callable [#call] the listener to remove.
97
+ # @return [Boolean] whether it was there.
98
+ def remove(callable)
99
+ index = @entries.index { _1.callable == callable }
100
+ return false if index.nil?
101
+
102
+ @entries.delete_at(index)
103
+ @claim_changed&.call(false) if @entries.empty?
104
+ true
105
+ end
106
+
107
+ # @param callable [#call]
108
+ # @return [Boolean] whether `callable` is registered.
109
+ def include?(callable) = @entries.any? { _1.callable == callable }
110
+
111
+ # @return [Boolean] whether nothing is registered — a state each slot gives
112
+ # its own meaning.
113
+ def empty? = @entries.empty?
114
+
115
+ # @return [Integer] how many listeners are registered, duplicates counted.
116
+ def size = @entries.size
117
+
118
+ # Yields each listener in registration order.
119
+ # @yieldparam callable [#call]
120
+ # @return [void]
121
+ def each
122
+ @entries.each { yield _1.callable }
123
+ end
124
+
125
+ # Calls every listener in registration order — so the gem's own listener
126
+ # runs before any app's, a widget having wired itself in its constructor.
127
+ #
128
+ # A listener that raises **aborts the fire**: the ones behind it do not run
129
+ # and the exception propagates, as a single slot did. Isolating each
130
+ # listener would turn a bug into a partial fire that nothing reports.
131
+ #
132
+ # @param event [Event] passed to every listener that declared a parameter.
133
+ # @return [void]
134
+ def fire(event)
135
+ # Snapshot: a listener may add or remove during the fire, and the
136
+ # newcomer is meant to run on the *next* one.
137
+ @entries.dup.each { _1.takes_event ? _1.callable.call(event) : _1.callable.call }
138
+ end
139
+
140
+ private
141
+
142
+ # @param callable [#call]
143
+ # @return [Boolean] whether {#fire} passes it the event.
144
+ # @raise [ArgumentError]
145
+ def takes_event?(callable)
146
+ raise ArgumentError, "#{@name}: expected a callable, got #{callable.inspect}" unless callable.respond_to?(:call)
147
+
148
+ arity = callable.is_a?(Proc) || callable.is_a?(Method) ? callable.arity : callable.method(:call).arity
149
+ required = arity.negative? ? -arity - 1 : arity
150
+ if required > 1
151
+ raise ArgumentError, "#{@name}: a listener takes the event or nothing, but #{callable.inspect} requires " \
152
+ "#{required} arguments"
153
+ end
154
+
155
+ # A negative arity means optional or splat parameters, which can absorb
156
+ # the event; only an exact zero declares it wants none.
157
+ !arity.zero?
158
+ end
159
+
160
+ # Declares listener slots on the class or module that extends it — what
161
+ # `attr_accessor` is to a plain attribute:
162
+ #
163
+ # module HasValue
164
+ # extend Listeners::Declare
165
+ #
166
+ # # @!method on_value_change
167
+ # # Fired whenever the value actually changes — never on a no-op set.
168
+ # # @return [Listeners]
169
+ # listener :on_value_change
170
+ # end
171
+ #
172
+ # The `@!method` directive is not decoration: a `define_method` reader is
173
+ # invisible to sord, so without it the slot vanishes from `sig/tuile.rbs` —
174
+ # and it is the rdoc the slot owes rubydoc.info anyway.
175
+ #
176
+ # Extending {Component} covers every widget, since a subclass inherits the
177
+ # singleton method.
178
+ module Declare
179
+ # Defines the slot's reader, which returns the {Listeners} — or, given a
180
+ # block, registers it and returns the `Proc`.
181
+ #
182
+ # An optional block is the slot's *transition* block, run on the owner
183
+ # whenever the list becomes non-empty or empty again. It is what lets a
184
+ # widget install a bridge only while somebody is listening:
185
+ #
186
+ # listener :on_enter do |claimed|
187
+ # claimed ? editor.on_enter << @bridge : editor.on_enter.remove(@bridge)
188
+ # end
189
+ #
190
+ # The list is built on first read, so no mixin has to remember a
191
+ # constructor line — which means in-class code goes through the reader and
192
+ # never touches `@on_foo`, nil until somebody asks.
193
+ #
194
+ # @param name [Symbol] the slot's **full** name, `on_`-prefixed. Spelling
195
+ # it out is what keeps `on_value_change` greppable from its declaration.
196
+ # @yieldparam claimed [Boolean] whether the list just became non-empty.
197
+ # @return [Symbol] `name`.
198
+ # @raise [Error] unless `name` starts with `on_`.
199
+ def listener(name, &claim_changed)
200
+ unless name.to_s.start_with?("on_")
201
+ raise Error, "listener :#{name} — a listener slot is named on_…, and declared this way the string " \
202
+ "on_#{name} appears nowhere in lib/ for a grep to find. Declare it as :on_#{name}."
203
+ end
204
+
205
+ ivar = :"@#{name}"
206
+ define_method(name) do |&block|
207
+ slot = instance_variable_get(ivar)
208
+ unless slot
209
+ owner = self
210
+ transition = claim_changed && ->(claimed) { owner.instance_exec(claimed, &claim_changed) }
211
+ slot = instance_variable_set(ivar, Listeners.new(name: name, &transition))
212
+ end
213
+ block ? slot.add(block) : slot
214
+ end
215
+ name
216
+ end
217
+ end
218
+ end
219
+ end
@@ -11,7 +11,9 @@ module Tuile
11
11
  # Every event resolves against one **path**: the topmost popup containing the
12
12
  # point, else the tiled content unless a modal popup is open
13
13
  # ({ScreenPane#mouse_root_at}), then down through the shown children whose
14
- # {Component#rect} contains it.
14
+ # {Component#rect} contains it. A `rect` is parent-relative, so the walk
15
+ # converts the point as it descends and each component is handed the event
16
+ # in **its own** coordinates — the same ones it paints in (`D_relative_rect`).
15
17
  #
16
18
  # - **{DownEvent}** — focuses the innermost {Component#focusable?} on that
17
19
  # path, then offers {Component#handle_mouse_down?} innermost-first until one
@@ -25,8 +27,9 @@ module Tuile
25
27
  #
26
28
  # The two geometries are deliberately different. Focus follows `rect`, so a
27
29
  # press on the dead tail a widget does not paint still focuses it; the
28
- # handlers bubble only along the prefix whose {Component#extent_rect} contains
29
- # the point, so that same press activates nothing (`D_extent`).
30
+ # handlers bubble only along the prefix whose
31
+ # {Component#local_extent_rect} contains the point, so that same press
32
+ # activates nothing (`D_extent`).
30
33
  #
31
34
  # UI-thread-confined.
32
35
  #
@@ -42,6 +45,14 @@ module Tuile
42
45
  # diffs, and {#sync_hover} — run by {Screen#repaint} — drops members that
43
46
  # were detached, hidden or reparented since, firing their exits.
44
47
  class Router
48
+ # One step of a resolved path: a component, and the event point in *its*
49
+ # coordinates. The walk down is the only place that conversion is cheap —
50
+ # it already holds the running offset — so it is done there once rather
51
+ # than by each component asking where it is.
52
+ # @api private
53
+ Hit = Data.define(:component, :point)
54
+ private_constant :Hit
55
+
45
56
  # @param screen [Screen]
46
57
  def initialize(screen)
47
58
  @screen = screen
@@ -118,7 +129,7 @@ module Tuile
118
129
  path = rect_path(root, point)
119
130
  pane.dismissing_popups_outside(point, left: event.button == :left) do
120
131
  focus_innermost(root, path) if event.button == :left
121
- claimant = bubble(path.take_while { _1.extent_rect.contains?(point) }, :handle_mouse_down?, event)
132
+ claimant = bubble(within_extent(path), :handle_mouse_down?, event)
122
133
  unless claimant.nil?
123
134
  @grabbed = claimant
124
135
  @grab_button = event.button
@@ -131,63 +142,79 @@ module Tuile
131
142
  def release(event)
132
143
  grabbed = @grabbed
133
144
  release_grab
134
- grabbed.__send__(:handle_mouse_up, event) if reachable?(grabbed)
145
+ return if grabbed.nil? || !ComponentUtil.effectively_visible?(grabbed)
146
+
147
+ local = grabbed.to_local(event.point)
148
+ grabbed.__send__(:handle_mouse_up, event.with(x: local.x, y: local.y))
135
149
  end
136
150
 
137
151
  # @param event [MoveEvent]
138
152
  # @return [void]
139
153
  def move(event)
140
154
  unless @grabbed.nil?
141
- drag = DragEvent.new(@grab_button, event.x, event.y)
142
- @grabbed.__send__(:handle_mouse_drag, drag) if reachable?(@grabbed)
155
+ if ComponentUtil.effectively_visible?(@grabbed)
156
+ local = @grabbed.to_local(event.point)
157
+ @grabbed.__send__(:handle_mouse_drag, DragEvent.new(@grab_button, local.x, local.y))
158
+ end
143
159
  return
144
160
  end
145
161
  return unless @level == :hover
146
162
 
147
163
  path = extent_path(event.point)
148
- rehover(path)
164
+ rehover(path.map(&:component))
149
165
  bubble(path, :handle_mouse_move?, event)
150
166
  end
151
167
 
152
168
  # A non-modal overlay is never focused into: it sits outside the key scope,
153
169
  # so focus there would make every keystroke go dead (`D_overlay`).
154
170
  # @param root [Component, nil]
155
- # @param path [Array<Component>]
171
+ # @param path [Array<Hit>]
156
172
  # @return [void]
157
173
  def focus_innermost(root, path)
158
174
  return if root.is_a?(Component::Overlay) && !root.modal?
159
175
 
160
- target = path.reverse_each.find(&:focusable?)
176
+ target = path.reverse_each.map(&:component).find(&:focusable?)
161
177
  @screen.focused = target unless target.nil? || target.active?
162
178
  end
163
179
 
164
- # @param path [Array<Component>] root first.
180
+ # @param path [Array<Hit>] root first.
165
181
  # @param handler [Symbol] a routed `handle_mouse_…?`.
166
- # @param event [Mouse::Event]
182
+ # @param event [Mouse::Event] in screen coordinates; each component is
183
+ # handed it converted to its own.
167
184
  # @return [Component, nil] the component that answered true.
168
185
  def bubble(path, handler, event)
169
186
  # A handler may detach what is below it on the path (a click that swaps
170
187
  # a slot's occupant), so re-check before each delivery.
171
- path.reverse_each.find { |c| c.attached? && c.__send__(handler, event) }
188
+ hit = path.reverse_each.find do |h|
189
+ h.component.attached? &&
190
+ h.component.__send__(handler, event.with(x: h.point.x, y: h.point.y))
191
+ end
192
+ hit&.component
172
193
  end
173
194
 
174
- # @param point [Point]
175
- # @return [Array<Component>] the shown components under `point` whose
176
- # extent contains it, root first.
177
- def extent_path(point)
178
- rect_path(@screen.pane.mouse_root_at(point), point).take_while { _1.extent_rect.contains?(point) }
179
- end
195
+ # @param point [Point] in screen coordinates.
196
+ # @return [Array<Hit>] the shown components under `point` whose extent
197
+ # contains it, root first.
198
+ def extent_path(point) = within_extent(rect_path(@screen.pane.mouse_root_at(point), point))
199
+
200
+ # @param path [Array<Hit>]
201
+ # @return [Array<Hit>] the prefix whose extent contains the point — the
202
+ # test each component answers in its own coordinates, so a widget that
203
+ # paints less than its rect has a dead tail (`D_extent`).
204
+ def within_extent(path) = path.take_while { _1.component.local_extent_rect.contains?(_1.point) }
180
205
 
181
206
  # @param root [Component, nil]
182
- # @param point [Point]
183
- # @return [Array<Component>] the shown components whose rect contains
184
- # `point`, root first. Tiled siblings never overlap, so at most one child
185
- # qualifies at each level.
207
+ # @param point [Point] in `root`'s parent's coordinates — screen
208
+ # coordinates, since every mouse root is a {ScreenPane} child.
209
+ # @return [Array<Hit>] the shown components whose rect contains `point`,
210
+ # root first, each paired with the point in its own coordinates. Tiled
211
+ # siblings never overlap, so at most one child qualifies at each level.
186
212
  def rect_path(root, point)
187
213
  path = []
188
214
  component = root
189
215
  while component&.visible? && component.rect.contains?(point)
190
- path << component
216
+ point = Point.new(point.x - component.rect.left, point.y - component.rect.top)
217
+ path << Hit.new(component:, point:)
191
218
  component = component.children.find { _1.visible? && _1.rect.contains?(point) }
192
219
  end
193
220
  path
@@ -201,17 +228,6 @@ module Tuile
201
228
  (old - chain).reverse_each { _1.__send__(:handle_mouse_exit) }
202
229
  (chain - old).each { _1.__send__(:handle_mouse_enter) }
203
230
  end
204
-
205
- # @param component [Component, nil]
206
- # @return [Boolean] whether `component` is attached and it and every
207
- # ancestor are shown.
208
- def reachable?(component)
209
- return false if component.nil? || !component.attached?
210
-
211
- cursor = component
212
- cursor = cursor.parent while cursor&.visible?
213
- cursor.nil?
214
- end
215
231
  end
216
232
  end
217
233
  end
data/lib/tuile/mouse.rb CHANGED
@@ -16,7 +16,13 @@ module Tuile
16
16
  module Mouse
17
17
  # Included by every mouse event class: a marker for `case`/`is_a?`, plus the
18
18
  # {#point} they share.
19
+ #
20
+ # The include must stay qualified — a bare `Event` in this namespace
21
+ # resolves right back to here, and the gem-wide marker would be silently
22
+ # lost.
19
23
  module Event
24
+ include Tuile::Event
25
+
20
26
  # @return [Point] the event's position.
21
27
  def point = Point.new(x, y)
22
28
  end
@@ -88,12 +94,34 @@ module Tuile
88
94
  MODES = { clicks: 1000, drag: 1002, hover: 1003 }.freeze
89
95
  private_constant :MODES
90
96
 
91
- # X10 button code layout: `button | 4 shift | 8 meta | 16 ctrl | 32 motion |
92
- # 64 wheel`, button 3 meaning "released" (`R_mouse_reporting`).
97
+ # The SGR encoding mode, requested alongside whichever {MODES} rung the
98
+ # level picked — encoding and reporting are orthogonal modes. A terminal
99
+ # that does not understand it ignores the DECSET and keeps sending X10
100
+ # (`R_mouse_reporting`).
101
+ # @return [Integer]
102
+ SGR_MODE = 1006
103
+ private_constant :SGR_MODE
104
+
105
+ # Button code layout, shared by both encodings: `button | 4 shift | 8 meta
106
+ # | 16 ctrl | 32 motion | 64 wheel` (`R_mouse_reporting`).
93
107
  # @return [Integer]
94
108
  MODIFIER_BITS = 4 | 8 | 16
95
109
  private_constant :MODIFIER_BITS
96
110
 
111
+ # @return [String] the X10 report prefix, followed by three biased bytes.
112
+ X10_PREFIX = "\e[M"
113
+ private_constant :X10_PREFIX
114
+
115
+ # @return [String] the SGR report prefix, followed by a {SGR_REPORT} body.
116
+ SGR_PREFIX = "\e[<"
117
+ private_constant :SGR_PREFIX
118
+
119
+ # `\e[<Cb;x;y` then `M` for a press and `m` for a release — decimal and
120
+ # uncapped, where X10 packs each coordinate into a byte and dies past 223.
121
+ # @return [Regexp]
122
+ SGR_REPORT = /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
123
+ private_constant :SGR_REPORT
124
+
97
125
  class << self
98
126
  # Normalizes a `capture_mouse:` argument to a level.
99
127
  # @param capture_mouse [Boolean, Symbol] `false`, `true` (== `:clicks`),
@@ -111,64 +139,103 @@ module Tuile
111
139
  end
112
140
  end
113
141
 
142
+ # The escape asking for that level, SGR encoding included:
143
+ #
144
+ # Mouse.start_tracking(:clicks) # => "\e[?1006h\e[?1000h"
145
+ #
114
146
  # @param level [Symbol] one of {LEVELS}.
115
147
  # @return [String] the escape enabling that level.
116
- def start_tracking(level) = "\e[?#{MODES.fetch(level)}h"
148
+ def start_tracking(level) = "\e[?#{SGR_MODE}h\e[?#{MODES.fetch(level)}h"
117
149
 
118
150
  # @param level [Symbol] one of {LEVELS}.
119
- # @return [String] the escape disabling that level.
120
- def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l"
151
+ # @return [String] the escape disabling that level, reporting first.
152
+ def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l\e[?#{SGR_MODE}l"
121
153
 
122
- # Whether `key` is a mouse report. True on the X10 `\e[M` prefix
123
- # regardless of length — {.parse} is the place that validates the full
124
- # 6-byte shape and raises on malformed input.
154
+ # Whether `key` is a mouse report — the X10 `\e[M` prefix or the SGR
155
+ # `\e[<` one, regardless of length. {.parse} is the place that validates
156
+ # the full shape and raises on malformed input.
125
157
  # @param key [String] key read via {Keys.getkey}
126
158
  # @return [Boolean]
127
- def report?(key) = key.start_with?("\e[M")
159
+ def report?(key) = key.start_with?(X10_PREFIX, SGR_PREFIX)
128
160
 
129
- # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y) into one of
130
- # {DownEvent}, {UpEvent}, {ScrollEvent} or {MoveEvent}. Modifier bits are
131
- # ignored.
161
+ # Parses a mouse report in either wire encoding into one of {DownEvent},
162
+ # {UpEvent}, {ScrollEvent} or {MoveEvent}. Modifier bits are ignored.
163
+ #
164
+ # Mouse.parse("\e[M !\"") # => X10: DownEvent[:left, 0, 0]
165
+ # Mouse.parse("\e[<0;1;1M") # => SGR: DownEvent[:left, 0, 0]
166
+ # Mouse.parse("\e[<0;1;1m") # => SGR: UpEvent[0, 0]
167
+ #
168
+ # Which encoding arrived is invisible in the result, deliberately: SGR
169
+ # names the button on a release and X10 cannot, so the button is dropped
170
+ # (`D_mouse_dispatch`).
132
171
  #
133
- # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
134
- # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
135
- # the upstream key-reader: a shorter prefix means the tail was lost on
136
- # the way in, and a longer one means we over-consumed into the next
137
- # escape sequence. We refuse to silently truncate either case because
138
- # the trailing `\e` of an over-read corrupts the *next* getkey, and the
139
- # corruption then surfaces as garbled keystrokes in focused inputs
140
- # rather than as a parser failure pointing at the actual cause.
172
+ # A prefix without a whole well-formed report raises rather than
173
+ # truncates: it is always a bug in the upstream key-reader — a short read
174
+ # lost the tail, a long one over-consumed into the next escape sequence —
175
+ # and swallowing it corrupts the *next* getkey, surfacing as garbled
176
+ # keystrokes in focused inputs rather than as a parser failure pointing at
177
+ # the cause.
141
178
  # @param key [String] key read via {Keys.getkey}
142
- # @return [Event, nil] `nil` if `key` is not a mouse report, or reports
179
+ # @return [Mouse::Event, nil] `nil` if `key` is not a mouse report, or reports
143
180
  # a wheel button beyond the four directions.
144
181
  # @raise [Tuile::Error] if `key` is a malformed mouse report
145
182
  def parse(key)
146
183
  return nil unless report?(key)
184
+
185
+ key.start_with?(SGR_PREFIX) ? parse_sgr(key) : parse_x10(key)
186
+ end
187
+
188
+ private
189
+
190
+ # @param key [String] a report known to carry {X10_PREFIX}.
191
+ # @return [Mouse::Event, nil]
192
+ def parse_x10(key)
147
193
  unless key.bytesize == 6
148
194
  raise Tuile::Error,
149
195
  "malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
150
196
  end
151
197
 
152
198
  code = (key[3].ord - 32) & ~MODIFIER_BITS
153
- # XTerm reports coordinates 1-based (column N is encoded as N + 32);
154
- # subtract 33 so that `x` and `y` are 0-based.
155
- x = key[4].ord - 33
156
- y = key[5].ord - 33
199
+ # Coordinates are 1-based and biased by 32, so - 33 lands them 0-based.
200
+ # X10's release is the anonymous code 3 exactly: button-less motion is
201
+ # 32 | 3 and the rightwards wheel 64 | 3, neither of them an up.
202
+ event(code, key[4].ord - 33, key[5].ord - 33, released: code == 3)
203
+ end
204
+
205
+ # @param key [String] a report known to carry {SGR_PREFIX}.
206
+ # @return [Mouse::Event, nil]
207
+ def parse_sgr(key)
208
+ match = SGR_REPORT.match(key)
209
+ unless match
210
+ raise Tuile::Error,
211
+ "malformed mouse event: expected \\e[<Cb;x;y and M or m, got #{key.inspect}"
212
+ end
213
+
214
+ code, x, y = match.values_at(1, 2, 3).map(&:to_i)
215
+ event(code & ~MODIFIER_BITS, x - 1, y - 1, released: match[4] == "m")
216
+ end
217
+
218
+ # The half both encodings share: a button code (unbiased, modifier bits
219
+ # already cleared) and 0-based coordinates become an event.
220
+ # @param code [Integer]
221
+ # @param x [Integer]
222
+ # @param y [Integer]
223
+ # @param released [Boolean] whether the report says a button came up.
224
+ # @return [Mouse::Event, nil]
225
+ def event(code, x, y, released:)
157
226
  low = code & 3
158
227
  if code.anybits?(64)
159
228
  direction = %i[up down left right][low]
160
229
  direction && ScrollEvent.new(direction, x, y)
230
+ elsif released
231
+ UpEvent.new(x, y)
161
232
  elsif code.anybits?(32)
162
233
  MoveEvent.new(button(low), x, y)
163
- elsif low == 3
164
- UpEvent.new(x, y)
165
234
  else
166
235
  DownEvent.new(button(low), x, y)
167
236
  end
168
237
  end
169
238
 
170
- private
171
-
172
239
  # @param low [Integer] the code's two low bits.
173
240
  # @return [Symbol, nil]
174
241
  def button(low) = %i[left middle right][low]
data/lib/tuile/point.rb CHANGED
@@ -10,5 +10,11 @@ module Tuile
10
10
  class Point < Data.define(:x, :y)
11
11
  # @return [String]
12
12
  def to_s = "#{x},#{y}"
13
+
14
+ # `(0, 0)`, named for the value rather than for a role. Every coordinate
15
+ # space has an origin, and {Canvas#origin} is a *different* point — where a
16
+ # canvas's zero lands on its backend, which is rarely this one.
17
+ # @return [Point]
18
+ ZERO = new(0, 0)
13
19
  end
14
20
  end
data/lib/tuile/rect.rb CHANGED
@@ -26,6 +26,15 @@ module Tuile
26
26
  Rect.new(point.x, point.y, width, height)
27
27
  end
28
28
 
29
+ # {#at}'s relative counterpart — the same size, shifted. What moves a
30
+ # rectangle between two coordinate spaces one offset apart, either way;
31
+ # paint and screen, a {Canvas#origin} apart, are the pair Tuile has.
32
+ # @param point [Point] added to {#left} and {#top}.
33
+ # @return [Rect] moved by `point`.
34
+ def moved_by(point)
35
+ Rect.new(left + point.x, top + point.y, width, height)
36
+ end
37
+
29
38
  # Centers the rectangle — keeps {#width} and {#height} but modifies
30
39
  # {#top} and {#left} so that the rectangle is centered on a screen.
31
40
  # @param screen_size [Size] screen size
@@ -43,6 +52,13 @@ module Tuile
43
52
  new_width == width && new_height == height ? self : Rect.new(left, top, new_width, new_height)
44
53
  end
45
54
 
55
+ # Half-open: the `left`/`top` edges are inside, `right`/`bottom` are not,
56
+ # so two abutting rectangles never both claim the cell they share.
57
+ #
58
+ # r = Rect.new(0, 0, 2, 2)
59
+ # r.contains?(Point.new(0, 0)) # => true
60
+ # r.contains?(Point.new(2, 0)) # => false — right edge is outside
61
+ #
46
62
  # @param point [Point]
47
63
  # @return [Boolean]
48
64
  def contains?(point)
@@ -61,6 +77,23 @@ module Tuile
61
77
  other.top + other.height <= top + height
62
78
  end
63
79
 
80
+ # The region both rectangles cover, in the coordinate space they share.
81
+ # Half-open edges, like {#contains?}.
82
+ #
83
+ # Disjoint rectangles yield an {#empty? empty} rectangle rather than `nil`,
84
+ # so folding a chain of them needs no nil test per level and the caller
85
+ # asks {#empty?} once at the end — which is what a clip resolved up an
86
+ # ancestor chain does.
87
+ # @param other [Rect]
88
+ # @return [Rect]
89
+ def intersect(other)
90
+ new_left = [left, other.left].max
91
+ new_top = [top, other.top].max
92
+ Rect.new(new_left, new_top,
93
+ [[left + width, other.left + other.width].min - new_left, 0].max,
94
+ [[top + height, other.top + other.height].min - new_top, 0].max)
95
+ end
96
+
64
97
  # @return [Size]
65
98
  def size = Size.new(width, height)
66
99