tuile 0.13.0 → 0.15.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
@@ -26,6 +26,13 @@ module Tuile
26
26
  # Nest boxes to vary the gap — a `Vertical.new(spacing: 0)` inside a
27
27
  # `Vertical.new(spacing: 1)` groups two rows tightly within a looser stack.
28
28
  #
29
+ # **Hiding a pane is {#remove}, not `Fixed[0]`** (see {Fixed} for why an
30
+ # empty rect is not hiding). {#add}'s `at:` is what makes it reversible —
31
+ # keep the index and the constraints on your side:
32
+ #
33
+ # remove(@sidebar) # hide: siblings reclaim the space
34
+ # add(@sidebar, Expand[1], at: 0) # show: back where it was
35
+ #
29
36
  # == Implementation details
30
37
  #
31
38
  # Every child-list mutation re-runs the whole pass, because in a box the
@@ -98,6 +105,7 @@ module Tuile
98
105
  #
99
106
  # add(field, Fixed[1], cross: Fixed[30], align: :center)
100
107
  # add([ok, cancel], Fixed[1])
108
+ # add(sidebar, Expand[1], at: 0) # back where it was, after a #remove
101
109
  #
102
110
  # @param child [Component, Enumerable<Component>]
103
111
  # @param main [Fixed, Percent, Expand] extent along the main axis.
@@ -105,24 +113,58 @@ module Tuile
105
113
  # @param align [Symbol] one of {ALIGNMENTS} — where a child narrower than
106
114
  # the cross extent sits. {Vertical} / {Horizontal} say which edge
107
115
  # `:start` is.
116
+ # @param at [Integer, nil] position among the existing children; appends
117
+ # when nil. An Enumerable is inserted in order from there. This is what
118
+ # makes hiding-by-{#remove} reversible — see the class doc.
108
119
  # @raise [ArgumentError] on an unknown constraint or alignment, or an
109
120
  # {Expand} passed as `cross` (see {Expand}).
110
121
  # @raise [TypeError] if `child` is not a {Component}.
111
122
  # @return [void]
112
- def add(child, main = Fixed[1], cross: Percent[100], align: :start)
123
+ def add(child, main = Fixed[1], cross: Percent[100], align: :start, at: nil)
113
124
  if child.is_a? Enumerable
114
- child.each { add(_1, main, cross:, align:) }
125
+ child.each_with_index { |c, i| add(c, main, cross:, align:, at: at && at + i) }
115
126
  return
116
127
  end
117
128
 
118
129
  validate_main(main)
119
130
  validate_cross(cross)
120
131
  validate_align(align)
121
- add_child(child)
132
+ add_child(child, at:)
122
133
  @placements[child] = { main:, cross:, align: }
123
134
  relayout
124
135
  end
125
136
 
137
+ # Re-constrains a child already in the layout and re-runs the pass. A
138
+ # `nil` argument keeps what that axis already had, so one can move alone:
139
+ #
140
+ # box.constrain(sidebar, Fixed[0]) # collapse it; cross: and align: stand
141
+ #
142
+ # `Fixed[0]` *collapses* — see {Fixed} for why that is not the same as
143
+ # hiding, and the class doc for what is.
144
+ #
145
+ # @param child [Component] a child of this layout.
146
+ # @param main [Fixed, Percent, Expand, nil] extent along the main axis.
147
+ # @param cross [Fixed, Percent, nil] extent across it.
148
+ # @param align [Symbol, nil] one of {ALIGNMENTS}.
149
+ # @raise [ArgumentError] if `child` is not a child of this layout, or on
150
+ # an unknown constraint or alignment.
151
+ # @return [void]
152
+ def constrain(child, main = nil, cross: nil, align: nil)
153
+ raise ArgumentError, "#{child} is not a child of #{self}" unless children.any? { _1.equal?(child) }
154
+
155
+ validate_main(main) unless main.nil?
156
+ validate_cross(cross) unless cross.nil?
157
+ validate_align(align) unless align.nil?
158
+
159
+ current = placement(child)
160
+ updated = { main: main || current[:main], cross: cross || current[:cross],
161
+ align: align || current[:align] }
162
+ return if current == updated
163
+
164
+ @placements[child] = updated
165
+ relayout
166
+ end
167
+
126
168
  # Removes the child, forgets its constraints, and closes the gap it left
127
169
  # by re-running the layout.
128
170
  # @param child [Component]
@@ -140,19 +182,34 @@ module Tuile
140
182
  relayout
141
183
  end
142
184
 
185
+ protected
186
+
187
+ # Re-divides the space: a child that went hidden gives its slot *and*
188
+ # the {#spacing} around it to its siblings, and one that came back takes
189
+ # them again with the constraints it was added with — which is what
190
+ # {Component#visible=} buys over `remove` plus `add(…, at:)`.
191
+ # @param _child [Component]
192
+ # @return [void]
193
+ def on_child_visibility_changed(_child) = relayout
194
+
143
195
  private
144
196
 
145
- # Recomputes and assigns every child's rect. Silent until this layout has
146
- # a rect of its own — {#add} runs during construction, long before a
147
- # parent assigns one.
197
+ # Recomputes and assigns every child's rect, giving each an empty one
198
+ # when this layout's own rect — or {#inner_rect} — is empty.
199
+ #
200
+ # Deliberately *no* `return if rect.empty?` guard: that strands the
201
+ # children at the coordinates they last had, and the next full repaint
202
+ # paints them there (`D_empty_ancestor`). Construction is silent without
203
+ # one anyway — {#add} runs before a parent assigns a rect, so the
204
+ # children are already empty and `invalidate` no-ops while detached.
148
205
  # @return [void]
149
206
  def relayout
150
- return if rect.empty?
151
-
152
207
  inner = inner_rect
153
- if inner.empty?
154
- children.each { _1.rect = Rect.new(rect.left, rect.top, 0, 0) }
208
+ collapsed = Rect.new(rect.left, rect.top, 0, 0)
209
+ if rect.empty? || inner.empty?
210
+ children.each { _1.rect = collapsed }
155
211
  else
212
+ children.each { _1.rect = collapsed unless _1.visible? }
156
213
  place_children(inner)
157
214
  end
158
215
  invalidate
@@ -168,20 +225,30 @@ module Tuile
168
225
  # @param inner [Rect] {#inner_rect}, known non-empty.
169
226
  # @return [void]
170
227
  def place_children(inner)
171
- sizes = main_sizes(inner)
228
+ kids = shown_children
229
+ sizes = main_sizes(inner, kids)
172
230
  available = cross_extent(inner)
173
231
  offset = 0
174
- children.each_with_index do |child, i|
232
+ kids.each_with_index do |child, i|
175
233
  cross_offset, cross_size = cross_placement(child, available)
176
234
  child.rect = build_rect(inner, offset, sizes[i], cross_offset, cross_size)
177
235
  offset += sizes[i] + spacing
178
236
  end
179
237
  end
180
238
 
239
+ # The children this pass divides space between — a hidden one is out of
240
+ # the {#spacing} count as well as the arithmetic, so hiding a middle
241
+ # child closes the row up rather than leaving a double gap. Contrast a
242
+ # `Fixed[0]` child, still a member of the sequence and still paying for
243
+ # its gap (`D_visibility`).
244
+ # @return [Array<Component>]
245
+ def shown_children = children.select(&:visible?)
246
+
181
247
  # @param inner [Rect] {#inner_rect}.
182
- # @return [Array<Integer>] main-axis extent per child, in child order.
183
- def main_sizes(inner)
184
- count = children.size
248
+ # @param kids [Array<Component>] {#shown_children}.
249
+ # @return [Array<Integer>] main-axis extent per shown child, in order.
250
+ def main_sizes(inner, kids)
251
+ count = kids.size
185
252
  return [] if count.zero?
186
253
 
187
254
  available = [main_extent(inner) - (spacing * (count - 1)), 0].max
@@ -189,7 +256,7 @@ module Tuile
189
256
  expanding = []
190
257
  unassigned = available
191
258
 
192
- children.each_with_index do |child, i|
259
+ kids.each_with_index do |child, i|
193
260
  case (constraint = placement(child)[:main])
194
261
  when Expand then expanding << i
195
262
  when Fixed then unassigned -= (sizes[i] = constraint.cells.clamp(0, unassigned))
@@ -197,19 +264,20 @@ module Tuile
197
264
  end
198
265
  end
199
266
 
200
- distribute_expand(sizes, expanding, unassigned) unless expanding.empty?
267
+ distribute_expand(sizes, kids, expanding, unassigned) unless expanding.empty?
201
268
  sizes
202
269
  end
203
270
 
204
271
  # Splits `slack` between the {Expand} children by weight, writing the
205
272
  # results into `sizes`.
206
273
  # @param sizes [Array<Integer>] mutated in place.
274
+ # @param kids [Array<Component>] {#shown_children}, which `indices` index.
207
275
  # @param indices [Array<Integer>] child indices carrying an {Expand}.
208
276
  # @param slack [Integer] cells left over; a negative value yields zeroes.
209
277
  # @return [void]
210
- def distribute_expand(sizes, indices, slack)
278
+ def distribute_expand(sizes, kids, indices, slack)
211
279
  slack = 0 if slack.negative?
212
- weights = indices.map { placement(children[_1])[:main].weight }
280
+ weights = indices.map { placement(kids[_1])[:main].weight }
213
281
  total = weights.sum
214
282
  shares = weights.map { slack * _1 / total }
215
283
  # Under one cell is lost per floor, so the remainder can't outrun the
@@ -23,7 +23,14 @@ module Tuile
23
23
  # add(prompt, Fixed[4]) # 4 rows in a Vertical
24
24
  # add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
25
25
  #
26
- # `Fixed[0]` hides the child — it gets an empty rect and paints nothing.
26
+ # `Fixed[0]` *collapses* the child: it and its whole subtree get an empty
27
+ # rect and paint nothing. That is not the same as hiding it — an empty
28
+ # rect is a paint convention and gates nothing else, so the subtree keeps
29
+ # its tab stops and still takes keys. **To hide a child, set
30
+ # {Component#visible=} false**; it then costs no {Box#spacing} gap either,
31
+ # where a collapsed child still does. The difference is exactly that: a
32
+ # collapsed child is still a member of the sequence, a hidden one is not
33
+ # (`D_visibility`).
27
34
  #
28
35
  # @!attribute [r] cells
29
36
  # @return [Integer] cell count along the axis.
@@ -190,16 +197,6 @@ module Tuile
190
197
  invalidate if @children.empty? # nothing left to paint over the gap
191
198
  end
192
199
 
193
- # Dispatches the event to the child under the mouse cursor.
194
- # @param event [MouseEvent]
195
- # @return [void]
196
- def handle_mouse(event)
197
- super
198
- @children.each do |child|
199
- child.handle_mouse(event) if child.rect.contains?(event.point)
200
- end
201
- end
202
-
203
200
  # @return [void]
204
201
  def on_focus
205
202
  super
@@ -209,12 +206,15 @@ module Tuile
209
206
  # containers like a {Window} or another {Layout}. Fall back to the
210
207
  # first focusable direct child for the rare case where the layout has
211
208
  # focusable but non-tab-stop children (e.g. an empty {Window}).
209
+ #
210
+ # Both halves skip hidden subtrees — this is the cascade that would
211
+ # otherwise walk straight back into the pane just hidden.
212
212
  first_tab_stop = nil
213
- on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
213
+ on_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
214
214
  if first_tab_stop
215
215
  screen.focused = first_tab_stop
216
216
  else
217
- first_focusable = @children.find(&:focusable?)
217
+ first_focusable = @children.find { _1.visible? && _1.focusable? }
218
218
  screen.focused = first_focusable unless first_focusable.nil?
219
219
  end
220
220
  end
@@ -564,7 +564,7 @@ module Tuile
564
564
  protected
565
565
 
566
566
  # Drops the rendered-row cache when the wrap width changes. The wrap
567
- # width depends on {#rect}`.width` and the scrollbar gutter, both of
567
+ # width depends on {#rect}`.width` and the scrollbar column, both of
568
568
  # which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
569
569
  # assigned while the rect was empty (e.g. a {Popup}-wrapped list was
570
570
  # populated before the popup was opened), the auto-scroll update
@@ -766,7 +766,7 @@ module Tuile
766
766
  end
767
767
 
768
768
  # @return [Integer] column width available for row content (rect width
769
- # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
769
+ # minus the scrollbar column, when visible). `0` when {#rect}'s width
770
770
  # is non-positive.
771
771
  def content_width
772
772
  return 0 if rect.width <= 0
@@ -809,9 +809,11 @@ module Tuile
809
809
  rendered.lines.first
810
810
  end
811
811
 
812
- # Pads `row` to one full row of the viewport (scrollbar gutter
813
- # excluded). Rows wider than the content area are ellipsized via
814
- # {StyledString#ellipsize} (span styles survive the cut); shorter
812
+ # Pads `row` to one full row of the viewport (scrollbar column
813
+ # excluded), including the one-column gutter either side — the trailing
814
+ # one is what keeps a row off the bar, as {TextView#scrollbar_columns}
815
+ # reserves separately. Rows wider than the content area are ellipsized
816
+ # via {StyledString#ellipsize} (span styles survive the cut); shorter
815
817
  # ones are padded with default-styled spaces.
816
818
  # @param row [StyledString]
817
819
  # @return [StyledString] exactly {#content_width} display columns wide
@@ -837,7 +839,10 @@ module Tuile
837
839
  base = index < @items.size ? padded_row(index) : blank_row
838
840
  is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
839
841
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
840
- styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
842
+ if scrollbar
843
+ styled += StyledString.styled(scrollbar.scrollbar_char(row_in_viewport),
844
+ fg: screen.theme.scrollbar_color)
845
+ end
841
846
  styled
842
847
  end
843
848
  end
@@ -4,7 +4,7 @@ module Tuile
4
4
  class Component
5
5
  # A borderless, tinted, non-focusable floating selection list — the dropdown
6
6
  # a *driver* drops open, drives by forwarding movement keys, and commits a
7
- # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
7
+ # pick from: an {Overlay} wrapping a {List} that never takes focus, so
8
8
  # focus stays on the driver while the caller refills the rows, moves the
9
9
  # highlight, and reads the pick.
10
10
  #
@@ -34,7 +34,7 @@ module Tuile
34
34
  # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
35
35
  #
36
36
  # UI-thread-confined, like every component (see {Screen}).
37
- class ListDropdown < Popup
37
+ class ListDropdown < Overlay
38
38
  # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
39
39
  # while focus stays on it, and a mouse click selects an item without
40
40
  # stealing focus — so a driving text input never loses its caret
@@ -51,6 +51,10 @@ module Tuile
51
51
  # caret; {Select} would spend a branch on what a second arrow press already
52
52
  # does) — and Enter/ESC, which carry driver-specific tails (see the class
53
53
  # docs).
54
+ #
55
+ # A driver only ever sees the keys its own children decline, so a
56
+ # {ComboBox} never gets Ctrl+U — its field claims it to clear the query —
57
+ # while {Select}, wrapping no editor, gets every one of these.
54
58
  # @return [Array<String>]
55
59
  MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
56
60
  Keys::CTRL_U, Keys::CTRL_D].freeze
@@ -64,7 +68,7 @@ module Tuile
64
68
  @list = Menu.new
65
69
  @list.cursor = List::Cursor.new
66
70
  @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
67
- super(content: @list, modal: false)
71
+ super(content: @list)
68
72
  self.bg_color = Theme.ref(:input_bg_color)
69
73
  end
70
74
 
@@ -124,7 +128,16 @@ module Tuile
124
128
  # Vertical flips but horizontal slides because covering the driver would
125
129
  # hide what is being chosen, while sharing its columns is the point.
126
130
  #
127
- # @param anchor [Rect] the driver's rect; the dropdown never covers it.
131
+ # **`anchor` is the region actually occupied, and may be taller than one
132
+ # row** — "beneath" means the row *after* it, so a multi-row driver (a
133
+ # {Component::TextArea} carrying an autocomplete menu) is cleared entirely
134
+ # rather than overdrawn from its second row down. A widget that paints one
135
+ # row but may be *assigned* more height passes its face, not its rect:
136
+ # {ComboBox} and {Select} both do, since a {Window} content slot hands them
137
+ # the full inner height.
138
+ #
139
+ # @param anchor [Rect] the region the driver occupies, of any height; the
140
+ # dropdown never covers it.
128
141
  # @param rows [Integer] how many rows there are to show — the content
129
142
  # count, not the height: more than fits turns the scrollbar on. `0`
130
143
  # collapses the dropdown to an empty rect (drivers close instead).
@@ -136,20 +149,20 @@ module Tuile
136
149
  # @return [void]
137
150
  def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
138
151
  desired = [rows, max_rows].min
139
- below = screen.size.height - (anchor.top + 1)
152
+ beneath = anchor.top + anchor.height
153
+ below = screen.size.height - beneath
140
154
  above = anchor.top
141
155
  if desired <= below
142
- top = anchor.top + 1
156
+ top = beneath
143
157
  height = desired
144
158
  elsif above >= below
145
159
  height = [desired, above].min
146
160
  top = anchor.top - height
147
161
  else
148
162
  height = below
149
- top = anchor.top + 1
163
+ top = beneath
150
164
  end
151
165
  width = [width, screen.size.width].min
152
- self.size = Size.new(width, height)
153
166
  self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
154
167
  # After the geometry: the setter rebuilds the list's padded rows against
155
168
  # the width it can see, and the gutter takes a column off it.
@@ -182,7 +195,7 @@ module Tuile
182
195
  # @param width [Integer] the panel's width in columns, clamped to the
183
196
  # screen. **Required, with no default:** `anchor.width` is the *parent's*
184
197
  # width and would be meaningless here, so the caller measures (see
185
- # `DECISIONS.md` `D-select` on why the width policy stays with the
198
+ # `DECISIONS.md` `D_select` on why the width policy stays with the
186
199
  # driver).
187
200
  # @param max_rows [Integer] rows shown before the list scrolls.
188
201
  # @return [void]
@@ -197,7 +210,6 @@ module Tuile
197
210
  end
198
211
  left = left.clamp(0, [screen.size.width - width, 0].max)
199
212
  top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
200
- self.size = Size.new(width, height)
201
213
  self.rect = Rect.new(left, top, width, height)
202
214
  # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
203
215
  # padded rows against the width it can see.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A {TextView} purpose-built for log output: auto-scroll is on, the
6
+ # scrollbar is visible, and lines arrive via {#log} — from any thread.
7
+ # Point a logger at a {LogTextView::IO} to route its lines here:
8
+ #
9
+ # view = Tuile::Component::LogTextView.new
10
+ # logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
11
+ # logger.info("started") # appears in the view, from any thread
12
+ #
13
+ # Any logger that writes formatted lines to an IO works the same way —
14
+ # for example `TTY::Logger` configured with the `:console` handler and
15
+ # `output: LogTextView::IO.new(view)`.
16
+ #
17
+ # Add it to a layout as-is for a frameless log pane, or use {LogWindow}
18
+ # for the framed assembly. A {TextView} rather than a {List}: long lines
19
+ # (stacktraces, wide log records) word-wrap rather than ellipsize — a
20
+ # truncated log line hides the very detail you opened the log to read.
21
+ class LogTextView < TextView
22
+ def initialize
23
+ super
24
+ self.auto_scroll = true
25
+ self.scrollbar_visibility = :visible
26
+ end
27
+
28
+ # Appends the given line to the log. Safe to call from any thread —
29
+ # the one exception to the UI-thread confinement every component
30
+ # carries: the append is marshalled through `event_queue.submit`, so
31
+ # it lands once a loop drains the queue (deferred before the first
32
+ # loop; silently dropped after the loop has returned).
33
+ # @param string [String, nil] the line (or multiple lines) to log;
34
+ # `nil` is a no-op.
35
+ # @return [void]
36
+ def log(string)
37
+ return if string.nil?
38
+
39
+ screen.event_queue.submit { add_line(string) }
40
+ end
41
+
42
+ # IO-shaped adapter that forwards each log line to its sink's `#log`.
43
+ # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
44
+ # call `output.puts`, e.g. `TTY::Logger`).
45
+ class IO
46
+ # @param sink [LogTextView, LogWindow] anything responding to `#log`.
47
+ def initialize(sink)
48
+ @sink = sink
49
+ end
50
+
51
+ # @param string [String]
52
+ # @return [void]
53
+ def write(string)
54
+ @sink.log(string.chomp)
55
+ end
56
+
57
+ # @param string [String]
58
+ # @return [void]
59
+ def puts(string)
60
+ @sink.log(string)
61
+ end
62
+
63
+ # Stdlib `Logger` only treats an object as an IO target when it
64
+ # responds to both {#write} and {#close}; otherwise it tries to
65
+ # interpret it as a filename. This is a no-op.
66
+ # @return [void]
67
+ def close; end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,65 +2,30 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
6
- # log lines into this window:
5
+ # A {Window} framing a {LogTextView} — the framed log pane. All the log
6
+ # behavior lives on the view (see {LogTextView} for the adapter and the
7
+ # wrap rationale); the window adds the border, caption and delegation:
7
8
  #
8
9
  # log_window = Tuile::Component::LogWindow.new
9
10
  # logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
10
- #
11
- # Any logger that writes formatted lines to an IO works the same way —
12
- # for example `TTY::Logger` configured with the `:console` handler and
13
- # `output: LogWindow::IO.new(window)`.
14
11
  class LogWindow < Window
12
+ # Alias of {LogTextView::IO}, which accepts the window itself as its sink.
13
+ # @return [Class]
14
+ IO = LogTextView::IO
15
+
15
16
  # @param caption [String]
16
17
  def initialize(caption = "Log")
17
18
  super
18
- view = Component::TextView.new
19
- # Word-wrap long lines (stacktraces, wide log records) rather than
20
- # ellipsizing them as a {List} would — a truncated log line hides the
21
- # very detail you opened the log to read.
22
- view.auto_scroll = true
23
- self.content = view
24
- self.scrollbar = true
19
+ self.content = LogTextView.new
25
20
  end
26
21
 
27
- # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
28
- # @param string [String, nil] the line (or multiple lines) to log.
22
+ # Appends the given line to the log, from any thread — delegates to
23
+ # {LogTextView#log}.
24
+ # @param string [String, nil] the line (or multiple lines) to log;
25
+ # `nil` is a no-op.
29
26
  # @return [void]
30
27
  def log(string)
31
- return if string.nil?
32
-
33
- screen.event_queue.submit do
34
- content.add_line(string)
35
- end
36
- end
37
-
38
- # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
39
- # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
40
- # call `output.puts`, e.g. `TTY::Logger`).
41
- class IO
42
- # @param window [LogWindow]
43
- def initialize(window)
44
- @window = window
45
- end
46
-
47
- # @param string [String]
48
- # @return [void]
49
- def write(string)
50
- @window.log(string.chomp)
51
- end
52
-
53
- # @param string [String]
54
- # @return [void]
55
- def puts(string)
56
- @window.log(string)
57
- end
58
-
59
- # Stdlib `Logger` only treats an object as an IO target when it
60
- # responds to both {#write} and {#close}; otherwise it tries to
61
- # interpret it as a filename. This is a no-op.
62
- # @return [void]
63
- def close; end
28
+ content.log(string)
64
29
  end
65
30
  end
66
31
  end
@@ -19,7 +19,7 @@ module Tuile
19
19
  #
20
20
  # Widths are measured here, per level — the panel is as wide as the level's
21
21
  # widest label — because {ListDropdown} deliberately measures nothing
22
- # itself (`DECISIONS.md` `D-select`).
22
+ # itself (`DECISIONS.md` `D_select`).
23
23
  #
24
24
  # == Implementation details
25
25
  # While open it consumes **everything** except the two keys that mean
@@ -33,7 +33,7 @@ module Tuile
33
33
  # The affordance painted on a row that opens a submenu. U+25B8 rather
34
34
  # than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
35
35
  # **Neutral**, so it measures one column even under ambiguous-as-wide and
36
- # stays outside `D-ambiguous-width`'s bet, where `▶` and `▼` are
36
+ # stays outside `D_ambiguous_width`'s bet, where `▶` and `▼` are
37
37
  # Ambiguous and would need an ASCII opt-in.
38
38
  # @return [String]
39
39
  SUBMENU_ARROW = "▸"
@@ -179,7 +179,7 @@ module Tuile
179
179
  drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
180
180
  # The cascade's own record of what is open is reconciled from the
181
181
  # popup's own closure, not maintained alongside it: an outside click
182
- # closes panels behind our back ({Popup#close_on_outside_click?}), and
182
+ # closes panels behind our back ({Overlay#close_on_outside_click?}), and
183
183
  # a level left in `@levels` after its panel is gone would have `depth`,
184
184
  # `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
185
185
  # because the notice also arrives from `truncate` (which has already
@@ -43,7 +43,7 @@ module Tuile
43
43
  # {Item} handles are minted by {#add_item} and nest via the *same* method, so
44
44
  # depth is unlimited. There is no removal, no reordering and no dynamic
45
45
  # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
46
- # `D-menu-bar`.
46
+ # `D_menu_bar`.
47
47
  #
48
48
  # == Sizing
49
49
  # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
@@ -244,11 +244,11 @@ module Tuile
244
244
  # tail — or on a lower row, when the rect is taller than one — opens
245
245
  # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
246
246
  # ungated by geometry.
247
- # @return [Rect]
247
+ # @return [Size]
248
248
  def extent
249
- return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
249
+ return Size.new(0, 1) if rect.empty?
250
250
 
251
- Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
251
+ Size.new([painted_width - @left_column, rect.width].min, 1)
252
252
  end
253
253
 
254
254
  # @param new_rect [Rect]
@@ -470,7 +470,7 @@ module Tuile
470
470
  # @return [Integer, nil] the index of the item painted at `point`; `nil`
471
471
  # for the blank tail or a row the strip doesn't paint.
472
472
  def index_at(point)
473
- return nil unless extent.contains?(point)
473
+ return nil unless extent_rect.contains?(point)
474
474
 
475
475
  column = point.x - rect.left + @left_column
476
476
  segments.index { |_item, start, width| column >= start && column < start + width }