tuile 0.14.0 → 0.16.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ module Mouse
5
+ # Delivers {Mouse::Event}s to the component tree. {Screen} owns one and hands
6
+ # it every event the terminal reports; a component only answers the handlers
7
+ # it calls, and walks nothing itself.
8
+ #
9
+ # screen.handle_mouse(Mouse::DownEvent.new(:left, 5, 2)) # what the loop does
10
+ #
11
+ # Every event resolves against one **path**: the topmost popup containing the
12
+ # point, else the tiled content unless a modal popup is open
13
+ # ({ScreenPane#mouse_root_at}), then down through the shown children whose
14
+ # {Component#rect} contains it.
15
+ #
16
+ # - **{DownEvent}** — focuses the innermost {Component#focusable?} on that
17
+ # path, then offers {Component#handle_mouse_down?} innermost-first until one
18
+ # component answers `true`. That claimant becomes {#grabbed}.
19
+ # - **{ScrollEvent}**, **{MoveEvent}** — bubble the same way through
20
+ # {Component#handle_mouse_scroll?} / {Component#handle_mouse_move?}, and grab
21
+ # nothing. A move fires the enter/exit hooks first, and arrives only at
22
+ # `:hover`.
23
+ # - **{DragEvent}**, **{UpEvent}** — skip the path entirely: they go to
24
+ # {#grabbed}, wherever the pointer is, and an up ends the grab.
25
+ #
26
+ # The two geometries are deliberately different. Focus follows `rect`, so a
27
+ # 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
+ #
31
+ # UI-thread-confined.
32
+ #
33
+ # == Implementation details
34
+ #
35
+ # **The grab has three releases and no relinquishing**: the up, the next
36
+ # down (the up was lost — ssh and tmux do lose them), and any key
37
+ # ({#release_grab}). None of the last two tells the grabbed component. Hiding
38
+ # or detaching it does not end the grab either; the router just stops
39
+ # delivering to it until the release (`D_mouse_dispatch`).
40
+ #
41
+ # **The hovered chain is synced, not toggled**: a move re-resolves it and
42
+ # diffs, and {#sync_hover} — run by {Screen#repaint} — drops members that
43
+ # were detached, hidden or reparented since, firing their exits.
44
+ class Router
45
+ # @param screen [Screen]
46
+ def initialize(screen)
47
+ @screen = screen
48
+ @level = :hover
49
+ @hovered = []
50
+ @grabbed = nil
51
+ @grab_button = nil
52
+ end
53
+
54
+ # The tracking level {Screen#run_event_loop} asked the terminal for, one of
55
+ # {Mouse::LEVELS}; `:hover` while no loop is running, so a spec may post
56
+ # anything. An ungrabbed move is dropped below `:hover` — under `:drag` the
57
+ # terminal reports one only while a button nobody claimed is held, and
58
+ # enter/exit that fire *sometimes* are worse than none.
59
+ # @return [Symbol, nil]
60
+ attr_accessor :level
61
+
62
+ # @return [Component, nil] the component whose {Component#handle_mouse_down?}
63
+ # claimed the press currently held.
64
+ attr_reader :grabbed
65
+
66
+ # @return [Component, nil] the innermost component under the pointer, as of
67
+ # the last move under `:hover`.
68
+ def hovered = @hovered.last
69
+
70
+ # @param event [Mouse::Event]
71
+ # @return [void]
72
+ def dispatch(event)
73
+ case event
74
+ when DownEvent then press(event)
75
+ when UpEvent then release(event)
76
+ when ScrollEvent then bubble(extent_path(event.point), :handle_mouse_scroll?, event)
77
+ when MoveEvent then move(event)
78
+ when DragEvent
79
+ # No route of its own: it is manufactured here from a move while
80
+ # something holds the grab, so a posted one can only be a mistake.
81
+ raise Tuile::Error,
82
+ "a Mouse::DragEvent is the router's own; post " \
83
+ "Mouse::MoveEvent.new(:#{event.button}, #{event.x}, #{event.y}) " \
84
+ "— or FakeScreen#drag — to drive a grabbed component"
85
+ else raise TypeError, "not a routable Mouse::Event: #{event.inspect}"
86
+ end
87
+ end
88
+
89
+ # Ends the grab without telling the grabbed component.
90
+ # @return [void]
91
+ def release_grab
92
+ @grabbed = nil
93
+ @grab_button = nil
94
+ end
95
+
96
+ # Drops the hovered-chain members that are no longer attached, shown, or
97
+ # children of the member before them, firing
98
+ # {Component#handle_mouse_exit} innermost first. Idempotent.
99
+ # @return [void]
100
+ def sync_hover
101
+ valid = @hovered.each_with_index.take_while do |c, i|
102
+ c.attached? && c.visible? && (i.zero? || c.parent.equal?(@hovered[i - 1]))
103
+ end.size
104
+ return if valid == @hovered.size
105
+
106
+ rehover(@hovered.take(valid))
107
+ end
108
+
109
+ private
110
+
111
+ # @param event [DownEvent]
112
+ # @return [void]
113
+ def press(event)
114
+ release_grab
115
+ point = event.point
116
+ pane = @screen.pane
117
+ root = pane.mouse_root_at(point)
118
+ path = rect_path(root, point)
119
+ pane.dismissing_popups_outside(point, left: event.button == :left) do
120
+ focus_innermost(root, path) if event.button == :left
121
+ claimant = bubble(path.take_while { _1.extent_rect.contains?(point) }, :handle_mouse_down?, event)
122
+ unless claimant.nil?
123
+ @grabbed = claimant
124
+ @grab_button = event.button
125
+ end
126
+ end
127
+ end
128
+
129
+ # @param event [UpEvent]
130
+ # @return [void]
131
+ def release(event)
132
+ grabbed = @grabbed
133
+ release_grab
134
+ grabbed.__send__(:handle_mouse_up, event) if reachable?(grabbed)
135
+ end
136
+
137
+ # @param event [MoveEvent]
138
+ # @return [void]
139
+ def move(event)
140
+ unless @grabbed.nil?
141
+ drag = DragEvent.new(@grab_button, event.x, event.y)
142
+ @grabbed.__send__(:handle_mouse_drag, drag) if reachable?(@grabbed)
143
+ return
144
+ end
145
+ return unless @level == :hover
146
+
147
+ path = extent_path(event.point)
148
+ rehover(path)
149
+ bubble(path, :handle_mouse_move?, event)
150
+ end
151
+
152
+ # A non-modal overlay is never focused into: it sits outside the key scope,
153
+ # so focus there would make every keystroke go dead (`D_overlay`).
154
+ # @param root [Component, nil]
155
+ # @param path [Array<Component>]
156
+ # @return [void]
157
+ def focus_innermost(root, path)
158
+ return if root.is_a?(Component::Overlay) && !root.modal?
159
+
160
+ target = path.reverse_each.find(&:focusable?)
161
+ @screen.focused = target unless target.nil? || target.active?
162
+ end
163
+
164
+ # @param path [Array<Component>] root first.
165
+ # @param handler [Symbol] a routed `handle_mouse_…?`.
166
+ # @param event [Mouse::Event]
167
+ # @return [Component, nil] the component that answered true.
168
+ def bubble(path, handler, event)
169
+ # A handler may detach what is below it on the path (a click that swaps
170
+ # a slot's occupant), so re-check before each delivery.
171
+ path.reverse_each.find { |c| c.attached? && c.__send__(handler, event) }
172
+ end
173
+
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
180
+
181
+ # @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.
186
+ def rect_path(root, point)
187
+ path = []
188
+ component = root
189
+ while component&.visible? && component.rect.contains?(point)
190
+ path << component
191
+ component = component.children.find { _1.visible? && _1.rect.contains?(point) }
192
+ end
193
+ path
194
+ end
195
+
196
+ # @param chain [Array<Component>] the new hovered chain, root first.
197
+ # @return [void]
198
+ def rehover(chain)
199
+ old = @hovered
200
+ @hovered = chain
201
+ (old - chain).reverse_each { _1.__send__(:handle_mouse_exit) }
202
+ (chain - old).each { _1.__send__(:handle_mouse_enter) }
203
+ 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
+ end
216
+ end
217
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The mouse: the events, their parser, and the tracking levels
5
+ # {Screen#run_event_loop} asks the terminal for. Who gets one is
6
+ # {Mouse::Router}'s story.
7
+ #
8
+ # Four of the five classes are what {.parse} reads off the wire; {DragEvent} is
9
+ # the router's own, a move while something holds the grab. Each is a
10
+ # `Data.define` including the {Event} marker — no inheritance, so a `case`
11
+ # matches either one class or `Mouse::Event` for all of them. Enter and exit
12
+ # have no class at all: nothing is parsed, they are the difference between two
13
+ # hovered chains.
14
+ #
15
+ # Coordinates are screen-absolute and 0-based everywhere.
16
+ module Mouse
17
+ # Included by every mouse event class: a marker for `case`/`is_a?`, plus the
18
+ # {#point} they share.
19
+ module Event
20
+ # @return [Point] the event's position.
21
+ def point = Point.new(x, y)
22
+ end
23
+
24
+ # A button went down.
25
+ #
26
+ # @!attribute [r] button
27
+ # @return [Symbol] `:left`, `:middle` or `:right`.
28
+ # @!attribute [r] x
29
+ # @return [Integer] 0-based column.
30
+ # @!attribute [r] y
31
+ # @return [Integer] 0-based row.
32
+ DownEvent = Data.define(:button, :x, :y) { include Event }
33
+
34
+ # A button came up. **Deliberately no `button`**: an up reaches only the
35
+ # component that claimed the press, and that grab already knows which button
36
+ # it holds (`D_mouse_dispatch`).
37
+ #
38
+ # @!attribute [r] x
39
+ # @return [Integer] 0-based column.
40
+ # @!attribute [r] y
41
+ # @return [Integer] 0-based row.
42
+ UpEvent = Data.define(:x, :y) { include Event }
43
+
44
+ # One wheel notch. A notch is not a button, which is why this is not a
45
+ # {DownEvent}.
46
+ #
47
+ # @!attribute [r] direction
48
+ # @return [Symbol] `:up`, `:down`, `:left` or `:right`.
49
+ # @!attribute [r] x
50
+ # @return [Integer] 0-based column.
51
+ # @!attribute [r] y
52
+ # @return [Integer] 0-based row.
53
+ ScrollEvent = Data.define(:direction, :x, :y) { include Event }
54
+
55
+ # The pointer moved and nothing holds the grab.
56
+ #
57
+ # @!attribute [r] button
58
+ # @return [Symbol, nil] the button held while moving, or nil.
59
+ # @!attribute [r] x
60
+ # @return [Integer] 0-based column.
61
+ # @!attribute [r] y
62
+ # @return [Integer] 0-based row.
63
+ MoveEvent = Data.define(:button, :x, :y) { include Event }
64
+
65
+ # The pointer moved while a component holds the grab.
66
+ #
67
+ # The one event {Router} *makes* rather than routes, so posting one to
68
+ # {Screen#handle_mouse} raises: drive a grabbed component with a
69
+ # {MoveEvent} carrying the held button, or with {FakeScreen#drag}.
70
+ #
71
+ # @!attribute [r] button
72
+ # @return [Symbol] the button whose press took the grab.
73
+ # @!attribute [r] x
74
+ # @return [Integer] 0-based column — may lie outside the grabbed
75
+ # component's rect, and even outside the screen's last known size.
76
+ # @!attribute [r] y
77
+ # @return [Integer] 0-based row.
78
+ DragEvent = Data.define(:button, :x, :y) { include Event }
79
+
80
+ # The `capture_mouse:` levels, each a strict superset of the one before, and
81
+ # each unlocking one tier of events: `:clicks` (mode 1000) gives
82
+ # down, up and scroll; `:drag` (1002) adds drag; `:hover` (1003) adds move,
83
+ # enter and exit (`R_mouse_reporting`).
84
+ # @return [Array<Symbol>]
85
+ LEVELS = %i[clicks drag hover].freeze
86
+
87
+ # @return [Hash{Symbol => Integer}] the DEC private mode each level sets.
88
+ MODES = { clicks: 1000, drag: 1002, hover: 1003 }.freeze
89
+ private_constant :MODES
90
+
91
+ # X10 button code layout: `button | 4 shift | 8 meta | 16 ctrl | 32 motion |
92
+ # 64 wheel`, button 3 meaning "released" (`R_mouse_reporting`).
93
+ # @return [Integer]
94
+ MODIFIER_BITS = 4 | 8 | 16
95
+ private_constant :MODIFIER_BITS
96
+
97
+ class << self
98
+ # Normalizes a `capture_mouse:` argument to a level.
99
+ # @param capture_mouse [Boolean, Symbol] `false`, `true` (== `:clicks`),
100
+ # or one of {LEVELS}.
101
+ # @return [Symbol, nil] the level; nil when tracking is off.
102
+ # @raise [ArgumentError] on anything else.
103
+ def level(capture_mouse)
104
+ case capture_mouse
105
+ when false, nil then nil
106
+ when true then :clicks
107
+ when *LEVELS then capture_mouse
108
+ else
109
+ raise ArgumentError,
110
+ "capture_mouse: expected true, false or one of #{LEVELS}, got #{capture_mouse.inspect}"
111
+ end
112
+ end
113
+
114
+ # @param level [Symbol] one of {LEVELS}.
115
+ # @return [String] the escape enabling that level.
116
+ def start_tracking(level) = "\e[?#{MODES.fetch(level)}h"
117
+
118
+ # @param level [Symbol] one of {LEVELS}.
119
+ # @return [String] the escape disabling that level.
120
+ def stop_tracking(level) = "\e[?#{MODES.fetch(level)}l"
121
+
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.
125
+ # @param key [String] key read via {Keys.getkey}
126
+ # @return [Boolean]
127
+ def report?(key) = key.start_with?("\e[M")
128
+
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.
132
+ #
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.
141
+ # @param key [String] key read via {Keys.getkey}
142
+ # @return [Event, nil] `nil` if `key` is not a mouse report, or reports
143
+ # a wheel button beyond the four directions.
144
+ # @raise [Tuile::Error] if `key` is a malformed mouse report
145
+ def parse(key)
146
+ return nil unless report?(key)
147
+ unless key.bytesize == 6
148
+ raise Tuile::Error,
149
+ "malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
150
+ end
151
+
152
+ 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
157
+ low = code & 3
158
+ if code.anybits?(64)
159
+ direction = %i[up down left right][low]
160
+ direction && ScrollEvent.new(direction, x, y)
161
+ elsif code.anybits?(32)
162
+ MoveEvent.new(button(low), x, y)
163
+ elsif low == 3
164
+ UpEvent.new(x, y)
165
+ else
166
+ DownEvent.new(button(low), x, y)
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ # @param low [Integer] the code's two low bits.
173
+ # @return [Symbol, nil]
174
+ def button(low) = %i[left middle right][low]
175
+ end
176
+ end
177
+ end