tuile 0.15.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -80
  3. data/README.md +49 -24
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +17 -16
  7. data/book/05-focus.md +106 -34
  8. data/book/06-theming.md +108 -38
  9. data/book/07-components.md +249 -46
  10. data/book/08-testing.md +134 -32
  11. data/book/10-locale.md +3 -3
  12. data/book/README.md +11 -10
  13. data/examples/file_commander.rb +52 -32
  14. data/examples/hello_world.rb +18 -5
  15. data/examples/sampler.rb +576 -146
  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 +96 -97
  21. data/lib/tuile/component/abstract_wrapping_field.rb +99 -58
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +27 -19
  24. data/lib/tuile/component/checkbox.rb +21 -19
  25. data/lib/tuile/component/checkbox_group.rb +17 -18
  26. data/lib/tuile/component/combo_box.rb +69 -64
  27. data/lib/tuile/component/confirm_window.rb +34 -27
  28. data/lib/tuile/component/date_field.rb +50 -18
  29. data/lib/tuile/component/date_time_field.rb +319 -0
  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 +99 -28
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +8 -15
  37. data/lib/tuile/component/has_placeholder.rb +1 -1
  38. data/lib/tuile/component/has_validation.rb +40 -14
  39. data/lib/tuile/component/has_value.rb +71 -17
  40. data/lib/tuile/component/integer_field.rb +7 -6
  41. data/lib/tuile/component/label.rb +8 -15
  42. data/lib/tuile/component/layout/absolute.rb +86 -0
  43. data/lib/tuile/component/layout/box.rb +38 -60
  44. data/lib/tuile/component/layout.rb +127 -13
  45. data/lib/tuile/component/list.rb +233 -120
  46. data/lib/tuile/component/list_dropdown.rb +151 -91
  47. data/lib/tuile/component/menu_bar/cascade.rb +102 -32
  48. data/lib/tuile/component/menu_bar.rb +102 -82
  49. data/lib/tuile/component/notification.rb +76 -49
  50. data/lib/tuile/component/overlay.rb +217 -58
  51. data/lib/tuile/component/password_field.rb +1 -8
  52. data/lib/tuile/component/picker_window.rb +41 -17
  53. data/lib/tuile/component/popup.rb +15 -26
  54. data/lib/tuile/component/progress_bar.rb +17 -11
  55. data/lib/tuile/component/radio_group.rb +16 -17
  56. data/lib/tuile/component/scroller.rb +266 -0
  57. data/lib/tuile/component/select.rb +26 -43
  58. data/lib/tuile/component/slot.rb +4 -5
  59. data/lib/tuile/component/tab_sheet.rb +27 -34
  60. data/lib/tuile/component/tabs.rb +49 -34
  61. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  62. data/lib/tuile/component/text_area.rb +32 -28
  63. data/lib/tuile/component/text_field.rb +68 -50
  64. data/lib/tuile/component/text_view.rb +157 -89
  65. data/lib/tuile/component/time_field.rb +51 -21
  66. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  67. data/lib/tuile/component/window.rb +27 -26
  68. data/lib/tuile/component.rb +653 -323
  69. data/lib/tuile/component_background.rb +177 -0
  70. data/lib/tuile/component_util.rb +43 -0
  71. data/lib/tuile/event.rb +29 -0
  72. data/lib/tuile/event_queue.rb +18 -4
  73. data/lib/tuile/fake_event_queue.rb +1 -1
  74. data/lib/tuile/fake_screen.rb +120 -7
  75. data/lib/tuile/keys.rb +15 -6
  76. data/lib/tuile/layout_pass.rb +180 -0
  77. data/lib/tuile/listeners.rb +219 -0
  78. data/lib/tuile/mouse/router.rb +233 -0
  79. data/lib/tuile/mouse.rb +244 -0
  80. data/lib/tuile/point.rb +6 -0
  81. data/lib/tuile/rect.rb +33 -0
  82. data/lib/tuile/screen.rb +510 -138
  83. data/lib/tuile/screen_pane.rb +185 -67
  84. data/lib/tuile/strict_layout.rb +127 -0
  85. data/lib/tuile/styled_string.rb +144 -14
  86. data/lib/tuile/testing/gestures.rb +35 -0
  87. data/lib/tuile/testing.rb +316 -42
  88. data/lib/tuile/theme.rb +192 -53
  89. data/lib/tuile/theme_def.rb +4 -0
  90. data/lib/tuile/version.rb +1 -1
  91. data/lib/tuile.rb +53 -0
  92. data/sig/tuile.rbs +6084 -1507
  93. metadata +19 -17
  94. data/COMPARISON.md +0 -101
  95. data/DECISIONS.md +0 -8562
  96. data/TERMINOLOGY.md +0 -85
  97. data/ideas/arrow-key-navigation.md +0 -221
  98. data/ideas/binder.md +0 -177
  99. data/ideas/composite-field.md +0 -77
  100. data/ideas/focus-accent.md +0 -116
  101. data/ideas/form-layout.md +0 -151
  102. data/ideas/hover/probe.rb +0 -241
  103. data/ideas/hover/probe_spec.rb +0 -82
  104. data/ideas/hover.md +0 -909
  105. data/ideas/modal-backdrop.md +0 -24
  106. data/ideas/new-components.md +0 -144
  107. data/ideas/per-component-buffers.md +0 -55
  108. data/lib/tuile/mouse_event.rb +0 -68
  109. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-column scrollbar the user can drag, and press the track of to page.
6
+ # It is chrome a container owns: you hand it a column, keep it told how
7
+ # tall the content is and how far it has scrolled, and listen for the row
8
+ # it asks for:
9
+ #
10
+ # @scrollbar = VerticalScrollBar.new(row_count: @content_rows)
11
+ # @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
12
+ # add_child(@scrollbar)
13
+ # # …and from wherever the geometry settles:
14
+ # @scrollbar.rect = Rect.new(rect.width - 1, 0, 1, rect.height)
15
+ # @scrollbar.scroll_top_row = @scroll_top_row
16
+ #
17
+ # **It never moves itself.** Both gestures compute a row and fire
18
+ # {#on_scroll_request}; nothing changes until the owner assigns
19
+ # {#scroll_top_row=}. So an unwired bar is inert — the honest picture,
20
+ # nothing behind it having scrolled either.
21
+ #
22
+ # **Dragging needs `run_event_loop(capture_mouse: :drag)`**: the `:clicks`
23
+ # default asks the terminal for no motion reports, so
24
+ # {Component#handle_mouse_drag} never fires and the handle sits still.
25
+ # Pressing the track works at every level.
26
+ #
27
+ # Its track is the whole {#rect} — no arrow buttons — so `rect.height` is
28
+ # the viewport height. The glyphs are the app-global {handle_char} /
29
+ # {track_char} pair and the color is `Theme#scrollbar_color`, read at paint
30
+ # time. {Scroller} is the worked example; why a component rather than drag
31
+ # handlers on the owner, and why the handle sits where it does, are
32
+ # `D_draggable_scrollbar`.
33
+ class VerticalScrollBar < Component
34
+ class << self
35
+ # The glyph drawn where the handle covers a row, `█` by default. Set
36
+ # the pair at startup for a lazygit-style bar:
37
+ #
38
+ # Tuile::Component::VerticalScrollBar.handle_char = "▐"
39
+ # Tuile::Component::VerticalScrollBar.track_char = "│"
40
+ #
41
+ # Process-global, and assigning invalidates nothing — a change after
42
+ # the first paint shows up only where something repaints anyway. Why
43
+ # app-global rather than per instance is `D_scrollbar_ink`.
44
+ # @return [String]
45
+ attr_reader :handle_char
46
+
47
+ # The glyph drawn on the rows the handle doesn't cover, `░` by
48
+ # default — and on every row when nothing scrolls ({#repaint}).
49
+ # @return [String]
50
+ attr_reader :track_char
51
+
52
+ # @param char [String]
53
+ # @return [String]
54
+ # @raise [TypeError] when `char` is not a String.
55
+ # @raise [ArgumentError] when `char` is not exactly one grapheme
56
+ # cluster one column wide.
57
+ def handle_char=(char)
58
+ @handle_char = StyledString.validate_glyph(char, :handle_char)
59
+ end
60
+
61
+ # @param char [String] see {handle_char=}.
62
+ # @return [String]
63
+ def track_char=(char)
64
+ @track_char = StyledString.validate_glyph(char, :track_char)
65
+ end
66
+ end
67
+
68
+ self.handle_char = "█"
69
+ self.track_char = "░"
70
+
71
+ # What {#on_scroll_request} fires.
72
+ #
73
+ # @!attribute [r] source
74
+ # @return [VerticalScrollBar] the bar that was dragged or pressed.
75
+ # @!attribute [r] scroll_top_row
76
+ # @return [Integer] the content row the user is asking to see at the
77
+ # top, clamped so it never asks past the last scrollable row.
78
+ ScrollRequestEvent = Data.define(:source, :scroll_top_row) { include Tuile::Event }
79
+
80
+ # @!method on_scroll_request
81
+ # Fired with a {ScrollRequestEvent} when a drag or a track press asks
82
+ # for a row other than the current one — never from {#scroll_top_row=},
83
+ # so an owner assigning the row it was just handed loops nothing.
84
+ #
85
+ # **Empty means a bar that does not move**: it reports the request and
86
+ # waits to be told, so nothing at all happens until somebody listens.
87
+ # @return [Listeners]
88
+ listener :on_scroll_request
89
+
90
+ # @param row_count [Integer] see {#row_count=}.
91
+ # @param scroll_top_row [Integer] see {#scroll_top_row=}.
92
+ def initialize(row_count: 0, scroll_top_row: 0)
93
+ super()
94
+ @row_count = validate_rows(row_count, :row_count)
95
+ @scroll_top_row = validate_rows(scroll_top_row, :scroll_top_row)
96
+ @drag_origin = nil
97
+ end
98
+
99
+ # @return [Integer] how many rows of content the bar stands for.
100
+ attr_reader :row_count
101
+
102
+ # @return [Integer] the content row currently at the top of the viewport.
103
+ attr_reader :scroll_top_row
104
+
105
+ # @param count [Integer] `>= 0`.
106
+ # @raise [ArgumentError] unless a non-negative Integer.
107
+ # @return [void]
108
+ def row_count=(count)
109
+ count = validate_rows(count, :row_count)
110
+ return if @row_count == count
111
+
112
+ @row_count = count
113
+ invalidate
114
+ end
115
+
116
+ # Not clamped against {#row_count} — the owner's scroll state is
117
+ # authoritative, and a bar that silently disagreed with it would paint a
118
+ # handle where the content is not.
119
+ # @param row [Integer] `>= 0`.
120
+ # @raise [ArgumentError] unless a non-negative Integer.
121
+ # @return [void]
122
+ def scroll_top_row=(row)
123
+ row = validate_rows(row, :scroll_top_row)
124
+ return if @scroll_top_row == row
125
+
126
+ @scroll_top_row = row
127
+ invalidate
128
+ end
129
+
130
+ # @return [Boolean] whether there is content out of view — `false` also
131
+ # for an empty rect, and what {#repaint} asks before drawing a handle.
132
+ def scrollable? = rect.height >= 1 && @row_count > rect.height
133
+
134
+ # Rows the handle covers, capped at one below the track so there is
135
+ # always somewhere to drag to: a 10-row viewport onto 11 rows of content
136
+ # gets a 9-row handle, not an immovable 10-row one.
137
+ # @return [Integer] `0` for an empty rect, the whole track when the
138
+ # content fits (nothing is *painted* then — see {#repaint}).
139
+ def handle_height
140
+ return rect.height unless scrollable?
141
+
142
+ (rect.height**2.0 / @row_count).ceil.clamp(1, [rect.height - 1, 1].max)
143
+ end
144
+
145
+ # The handle's first row, mapped over the *free* track so that the last
146
+ # scrollable row puts the handle's last row at the bottom of the track.
147
+ # @return [Integer] 0-based row within the track; `0` when the content
148
+ # fits.
149
+ def handle_start
150
+ free = free_rows
151
+ return 0 if free.zero?
152
+
153
+ (free * @scroll_top_row.clamp(0, max_top) / max_top.to_f).round
154
+ end
155
+
156
+ # One column wide, however wide a rect it was handed — which is also what
157
+ # the router hit-tests, so a press on the dead columns beside the bar
158
+ # reaches whatever is behind it instead.
159
+ # @return [Size]
160
+ def extent = Size.new([1, rect.width].min, rect.height)
161
+
162
+ # Claims every left press: on the handle it starts a drag, on the track
163
+ # it pages towards the pointer.
164
+ # @param event [Mouse::DownEvent]
165
+ # @return [Boolean]
166
+ def handle_mouse_down?(event)
167
+ return false unless event.button == :left
168
+
169
+ @drag_origin = nil
170
+ if scrollable? && event.y >= handle_start && event.y < handle_start + handle_height
171
+ @drag_origin = [event.y, @scroll_top_row]
172
+ else
173
+ request(@scroll_top_row + (event.y < handle_start ? -page_rows : page_rows))
174
+ end
175
+ true
176
+ end
177
+
178
+ # Scrolls by how far the pointer has moved *since the press*, not by
179
+ # where it is. Absolute positioning would jump on the first report: the
180
+ # track has a row per several rows of content, so inverting the handle's
181
+ # rounded position does not give back the row it came from.
182
+ # @param event [Mouse::DragEvent]
183
+ # @return [void]
184
+ def handle_mouse_drag(event)
185
+ super
186
+ return if @drag_origin.nil? || free_rows.zero?
187
+
188
+ y_at_press, top_at_press = @drag_origin
189
+ request(top_at_press + ((event.y - y_at_press) * max_top / free_rows.to_f).round)
190
+ end
191
+
192
+ # @param event [Mouse::UpEvent]
193
+ # @return [void]
194
+ def handle_mouse_up(event)
195
+ super
196
+ @drag_origin = nil
197
+ end
198
+
199
+ # Paints {track_char} down the column with {handle_char} over it — and
200
+ # bare track when {#scrollable?} is false, a solid handle filling the
201
+ # track carrying no information (`D_scrollbar_ink`).
202
+ # @param canvas [Canvas] see {Component#repaint}.
203
+ # @return [void]
204
+ def repaint(canvas)
205
+ super
206
+ return if rect.empty?
207
+
208
+ style = StyledString::Style.new(fg: screen.theme.scrollbar_color)
209
+ handle = (handle_start...handle_start + handle_height) if scrollable?
210
+ rect.height.times do |row|
211
+ # Named, not `self.class` — a class-level ivar is not inherited, so a
212
+ # subclassed bar would read nil off its own class.
213
+ glyph = handle&.cover?(row) ? VerticalScrollBar.handle_char : VerticalScrollBar.track_char
214
+ canvas.set_char(0, row, glyph, style)
215
+ end
216
+ end
217
+
218
+ # @return [Array<String>]
219
+ def inspect_details = super + ["#{scroll_top_row}/#{row_count} rows"]
220
+
221
+ private
222
+
223
+ # @return [Integer] the largest {#scroll_top_row} that still fills the
224
+ # viewport; `0` when the content fits.
225
+ def max_top = [@row_count - rect.height, 0].max
226
+
227
+ # @return [Integer] track rows the handle can travel over; `0` means no
228
+ # drag is possible.
229
+ def free_rows = scrollable? ? rect.height - handle_height : 0
230
+
231
+ # @return [Integer] a viewport, at least one row.
232
+ def page_rows = [rect.height, 1].max
233
+
234
+ # Fires {#on_scroll_request} for `row`, clamped; silent on a no-op.
235
+ # @param row [Integer]
236
+ # @return [void]
237
+ def request(row)
238
+ row = row.clamp(0, max_top)
239
+ return if row == @scroll_top_row
240
+
241
+ on_scroll_request.fire(ScrollRequestEvent.new(source: self, scroll_top_row: row))
242
+ end
243
+
244
+ # @param rows [Integer]
245
+ # @param name [Symbol] the accessor, for the message.
246
+ # @return [Integer] `rows`.
247
+ # @raise [ArgumentError] unless `rows` is a non-negative Integer.
248
+ def validate_rows(rows, name)
249
+ unless rows.is_a?(Integer) && !rows.negative?
250
+ raise ArgumentError, "#{name} expects a non-negative Integer, got #{rows.inspect}"
251
+ end
252
+
253
+ rows
254
+ end
255
+ end
256
+ end
257
+ end
@@ -66,17 +66,10 @@ module Tuile
66
66
  # @return [void]
67
67
  def footer=(new_footer)
68
68
  @footer_slot.content = new_footer
69
- layout_footer
69
+ invalidate_layout
70
70
  invalidate # repaint border row that the footer covers/uncovers
71
71
  end
72
72
 
73
- # @param new_rect [Rect]
74
- # @return [void]
75
- def rect=(new_rect)
76
- super
77
- layout_footer
78
- end
79
-
80
73
  # @param value [Boolean]
81
74
  # @return [void]
82
75
  def scrollbar=(value)
@@ -88,7 +81,7 @@ module Tuile
88
81
  content.scrollbar_visibility = value ? :visible : :gone
89
82
  @border_right = value ? 0 : 1
90
83
  invalidate
91
- layout(content)
84
+ invalidate_layout
92
85
  end
93
86
 
94
87
  # Fully repaints the window: the border ring here, the interior through
@@ -103,57 +96,65 @@ module Tuile
103
96
  # (`D_component_contract`). The ring is this window's own paint and the
104
97
  # interior is the content's, so the only cell nobody covers is an
105
98
  # interior with no content in it — cleared here, exactly.
99
+ # @param canvas [Canvas] see {Component#repaint}.
106
100
  # @return [void]
107
- def repaint
101
+ def repaint(canvas)
108
102
  return if rect.empty?
109
103
 
110
- clear_background(content_rect) if content.nil? && !content_rect.empty?
104
+ inner = content_rect
105
+ canvas.fill(inner) if content.nil? && !inner.empty?
111
106
  invalidate_children
112
- repaint_border
107
+ repaint_border(canvas)
113
108
  end
114
109
 
115
110
  protected
116
111
 
117
- # @param content [Component]
112
+ # The content takes the interior, the footer slot the bottom border row.
118
113
  # @return [void]
119
- def layout(content) = content.rect = content_rect
114
+ def relayout
115
+ content&.rect = content_rect
116
+ layout_footer
117
+ end
120
118
 
121
119
  # The interior the content fills: inside the border on three sides, and on
122
120
  # the fourth only while there is a right border — {#scrollbar=} drops it so
123
121
  # the content's own bar takes that column.
122
+ #
123
+ # One rect for both jobs, since a window paints and places its children in
124
+ # the same coordinates: it is what {#relayout} assigns the content, and what
125
+ # {#repaint} blanks when there is no content to cover it.
124
126
  # @return [Rect] may be {Rect#empty? empty}, for a window too small to have
125
127
  # an inside.
126
128
  def content_rect
127
- Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
129
+ Rect.new(1, 1, rect.width - 1 - @border_right, rect.height - 2)
128
130
  end
129
131
 
130
- # Paints the window border via {Component#draw_text}/{Component#draw_char},
131
- # so the border cells inherit {Component#effective_bg_color} — a
132
+ # Paints the window border via {Canvas#set_text} / {Canvas#set_char},
133
+ # so the border cells inherit {ComponentBackground#effective} — a
132
134
  # {Component#bg_color} on the window tints border and content alike. Both
133
135
  # border rows are clipped by *display* width, so no caption overflows the
134
136
  # box; when the window is active the whole border — the caption's own
135
137
  # colors included — is drawn in {Theme#active_border_color}.
138
+ # @param canvas [Canvas] the surface to paint onto.
136
139
  # @return [void]
137
- def repaint_border
140
+ def repaint_border(canvas)
138
141
  return if rect.empty?
139
142
 
140
143
  w = rect.width
141
144
  h = rect.height
142
- top = rect.top
143
- left = rect.left
144
145
  inner_w = [w - 2, 0].max
145
146
 
146
147
  fg = active? ? screen.theme.active_border_color : nil
147
148
  bar = StyledString::Style.new(fg: fg)
148
- draw_text(left, top, top_border(inner_w, fg).slice(0, w))
149
- (1..(h - 2)).each do |dy|
150
- draw_char(left, top + dy, "│", bar)
149
+ canvas.set_text(0, 0, top_border(inner_w, fg).slice(0, w))
150
+ (1..(h - 2)).each do |row|
151
+ canvas.set_char(0, row, "│", bar)
151
152
  # Skipped once {#scrollbar=} has given that column to the content: the
152
153
  # bar would paint over the border anyway, and painting it first only
153
154
  # dirties the column into every frame's diff (`D_component_contract`).
154
- draw_char(left + w - 1, top + dy, "│", bar) if @border_right.positive?
155
+ canvas.set_char(w - 1, row, "│", bar) if @border_right.positive?
155
156
  end
156
- draw_text(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
157
+ canvas.set_text(0, h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
157
158
  end
158
159
 
159
160
  # Builds the top border row: corners, {#caption} embedded at its own
@@ -204,7 +205,7 @@ module Tuile
204
205
  end
205
206
 
206
207
  width = [rect.width - 2, 0].max
207
- @footer_slot.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
208
+ @footer_slot.rect = Rect.new(1, rect.height - 1, width, 1)
208
209
  end
209
210
  end
210
211
  end