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
data/book/08-testing.md CHANGED
@@ -66,16 +66,21 @@ components don't emit escape sequences — they write styled cells into
66
66
  that means the buffer *is* the rendered screen, sitting in memory, fully
67
67
  inspectable, before any diffing or I/O. You assert against it directly.
68
68
 
69
- The rhythm is: build the component, give it a `rect`, repaint, read the
70
- buffer back over that rect.
69
+ The rhythm is: build the component, have a parent place it at a rect,
70
+ repaint, read the buffer back over that rect. A component never takes a
71
+ rect from anyone but its parent's `relayout` — `rect=` raises anywhere
72
+ else — so a test holds it in a `Layout::Absolute`, which places each child
73
+ exactly where it was added:
71
74
 
72
75
  ```ruby
73
76
  label = Component::Label.new
74
- label.rect = Rect.new(0, 0, 10, 1)
75
77
  label.text = "hi"
76
- label.repaint
78
+ holder = Component::Layout::Absolute.new
79
+ holder.add(label, Rect.new(0, 0, 10, 1))
80
+ Screen.instance.content = holder
81
+ Screen.instance.repaint
77
82
 
78
- assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
83
+ assert_equal ["hi "], Screen.instance.buffer.region_text(label.absolute_rect)
79
84
  ```
80
85
 
81
86
  {Tuile::Buffer#region_text} returns the plain text of each row in the
@@ -88,6 +93,29 @@ active-background, say, or that a theme flip changed a hint's hue. And
88
93
  pinpoint check. Everything is scoped to a `rect`, so you assert about a
89
94
  component's own region without caring what surrounds it.
90
95
 
96
+ That is what the *user* sees at those cells, a popup drawn over them
97
+ included. Often a test wants something narrower — what this one component
98
+ paints — and {Tuile::Testing.paint} answers that without a screen round
99
+ trip. It paints the component and its whole subtree into a {Tuile::Buffer}
100
+ of the component's own size, whose `(0, 0)` is the component's top-left:
101
+
102
+ ```ruby
103
+ window = Component::Window.new("Settings")
104
+ window.content = Component::Label.new("hi")
105
+ Testing.place(window, Rect.new(0, 0, 12, 3)) # sized; nothing to attach
106
+
107
+ assert_equal ["┌Settings──┐", "│hi │", "└──────────┘"], Testing.paint(window).text
108
+ ```
109
+
110
+ `Testing.place` puts a component at a rect through whatever places it —
111
+ here a throwaway holder, since `window` has no parent — because `rect=`
112
+ raises anywhere but a parent's `relayout`. It never attaches, and
113
+ `Testing.paint` doesn't need it to. Because the buffer is the component's
114
+ own, `cell(1, 0)` means column 1 *of the window*, and the classic mistake of
115
+ reading the screen at `rect` instead of `absolute_rect` has nowhere to
116
+ happen. Ancestors don't clip the paint, though their background shows
117
+ through; popups aren't in the subtree, so they don't show at all.
118
+
91
119
  What you do *not* assert content against is `prints`. On a FakeScreen,
92
120
  `prints` captures only what actually went "to the wire" — cursor
93
121
  positioning, housekeeping escapes, and the assembled frame string. Content
@@ -107,17 +135,33 @@ method* assembled, and the test never held a reference to it.
107
135
  one component matching a spec:
108
136
 
109
137
  ```ruby
110
- Testing.get(Component::Button, caption: "Save").handle_key?(Keys::ENTER)
138
+ Testing.get(id: :save).handle_key?(Keys::ENTER)
111
139
  Testing.get(id: :amount).value = 42
112
140
  ```
113
141
 
114
- The spec is a class, an `id`, a caption, a block, or any combination of
115
- them — never a path through the hierarchy, which would break every time you
116
- nested one more layout. The class slot also takes a *mixin*, which is where
117
- the `Has*` family from chapter 7 pays off a second time:
142
+ Those two lines drive the component directly, which is the right altitude for
143
+ some tests and too low for others; the gestures later in this chapter are the
144
+ same two lines with "could a user have done this?" asked first.
145
+
146
+ The spec is a class, an `id`, a block, or any combination of them — never a
147
+ path through the hierarchy, which would break every time you nested one more
148
+ layout. The class slot also takes a *mixin*, which is where the `Has*` family
149
+ from chapter 7 pays off a second time:
118
150
  `Testing.find(Component::HasBadInput)` finds every field in the tree whose
119
151
  parse can fail, whatever their classes.
120
152
 
153
+ Notice what is *not* on that list: there is no way to look a component up by
154
+ the text it shows. That is deliberate. Those handles are structural — they
155
+ describe where a component sits and what kind of thing it is — while a caption
156
+ is copy, and copy gets reworded by people who are not thinking about your
157
+ specs. A test that breaks because "Save" became "Save changes" has told you
158
+ nothing. When you really do want the text, the block says so and reads better
159
+ for being explicit about the class:
160
+
161
+ ```ruby
162
+ Testing.get(Component::Button) { _1.caption.to_s == "Save" }
163
+ ```
164
+
121
165
  The `id` in that second line is a plain `Symbol` tag you set on any
122
166
  component, purely so a test can ask for it back:
123
167
 
@@ -196,16 +240,19 @@ by hand tests a third of what a click is. Drive it through
196
240
  {Tuile::FakeScreen}, which posts the gesture the terminal would:
197
241
 
198
242
  ```ruby
199
- screen.content = list # a press focuses; focus needs a tree
200
- list.rect = Rect.new(0, 0, 10, 5)
243
+ holder = Component::Layout::Absolute.new
244
+ holder.add(list, Rect.new(0, 0, 10, 5))
245
+ screen.content = holder # a press focuses; focus needs a tree
201
246
  screen.click(5, 2) # press then release, at that cell
202
247
  ```
203
248
 
204
249
  `click` is the whole gesture; `press` / `release` are its halves, for a test
205
250
  about what the grab does in between, and `scroll` / `move` post the other two
206
- events. They all take screen-absolute, 0-based coordinates, so a test asserts
207
- against the rect it assigned — and a press on a cell no component covers
208
- simply does nothing.
251
+ events. They all take screen-absolute, 0-based coordinates, because that is what a
252
+ terminal reports — so a test clicks at `button.absolute_rect.left`, not at
253
+ `button.rect.left`, which is measured inside the button's parent. The component
254
+ receives the press back in its own coordinates. A press on a cell no component
255
+ covers simply does nothing.
209
256
 
210
257
  **High: go through the pane.** {Tuile::ScreenPane#handle_key?} runs the
211
258
  dispatch rung from chapter 5 that routing is actually about: delivery to
@@ -239,6 +286,59 @@ invalidates and lets the loop coalesce.) And to check invalidation itself
239
286
  `Screen.instance.invalidated?(component)` and `invalidated_clear` let you
240
287
  assert on the set directly.
241
288
 
289
+ ## Gestures: locating and driving in one line
290
+
291
+ Put the two halves together and a gap appears between them. `Testing.get`
292
+ refuses to hand you a hidden component, because it simulates a user — but the
293
+ moment you have a handle, `handle_key?` and `value=` will do as they are told
294
+ no matter what. A button under an open modal popup, a field in a collapsed
295
+ panel: both drive perfectly from a test, and both are unreachable in the app
296
+ you are shipping. The test passes; the feature is broken.
297
+
298
+ The gestures close it. Each one asks whether a user could have done this, and
299
+ raises with a dump of the tree when the answer is no:
300
+
301
+ ```ruby
302
+ Testing.click(Testing.get(Component::Button, id: :save))
303
+ Testing.set_value(Testing.get(Component::TextField, id: :name), "Zaphod")
304
+ ```
305
+
306
+ `Testing.click` is `screen.click` aimed by component rather than by cell: it
307
+ finds the top-left cell the component actually paints, checks a press there
308
+ *reaches* it, and posts the real press and release. What it refuses is
309
+ everything that would have clicked nothing — hidden, collapsed, or covered:
310
+
311
+ ```
312
+ #<Button id=:save rect=(1,1 8x1) caption="Save"> is not clickable at 1,1:
313
+ a press there reaches nothing — a modal popup is open
314
+ ```
315
+
316
+ It does *not* raise when the press lands and nobody claims it. Clicking a
317
+ `Label` is a thing a user can really do, and nothing happens; the gesture
318
+ asserts the click was possible, not that it achieved something.
319
+
320
+ `Testing.set_value` asks the keyboard's version of the same question: a field
321
+ outside the current key scope — behind an open modal — refuses. It moves no
322
+ focus, since no keystroke is involved, and assigns through `value=`, so it is
323
+ the value-level shortcut rather than a simulation of typing: the editor's input
324
+ filters never run.
325
+
326
+ Written out, those calls nest inside-out. Activate the refinement and they read
327
+ in the order they happen:
328
+
329
+ ```ruby
330
+ using Tuile::Testing::Gestures # top of the file, or inside one describe
331
+
332
+ Testing.get(Component::TextField, id: :name)._value = "Zaphod"
333
+ Testing.get(Component::Button, id: :save)._click
334
+ ```
335
+
336
+ The leading underscore is a borrowing from Karibu-Testing, and it earns its keep
337
+ on the first line: `_value =` sits one character from a real `value=`, and the
338
+ mark is what tells a reader which is running. Being a refinement, it exists only
339
+ where you `using` it — per file or per `describe`, never leaking to the next —
340
+ and nothing is added to `Component`, so an app never sees it.
341
+
242
342
  ## Why background code just works
243
343
 
244
344
  Chapter 4's rule was that background threads marshal UI work back with
data/book/10-locale.md CHANGED
@@ -201,7 +201,7 @@ Your own code does the same for a date you rendered into a
201
201
  the listener:
202
202
 
203
203
  ```ruby
204
- label.on_locale_changed = -> { label.text = due.strftime(screen.locale.date_formats.first) }
204
+ label.on_locale_changed { label.text = due.strftime(screen.locale.date_formats.first) }
205
205
  ```
206
206
 
207
207
  One consequence to accept rather than defend against: a field holding a
data/book/README.md CHANGED
@@ -18,7 +18,7 @@ links to the rdoc rather than restating it.
18
18
 
19
19
  Chapters 1–2 are the **base vocabulary** — the component tree and the
20
20
  repaint model that every later chapter leans on. Chapter 3 is the heart
21
- of Tuile's design: layout is top-down and absolute, and the chapter
21
+ of Tuile's design: layout is top-down and parent-relative, and the chapter
22
22
  argues *why that is enough* — the "C64" case for hand-placed
23
23
  coordinates on a character grid — rather than reaching for the
24
24
  negotiated min/pref/max machinery of desktop and web toolkits.
@@ -47,13 +47,13 @@ one, not to fill an outline.
47
47
  2. **[How the screen repaints](02-repaint.md).** Why components never
48
48
  write to the terminal directly. `invalidate` → the back buffer →
49
49
  a minimal diff → one synchronized flush per tick. The "cover your
50
- own `rect`" contract, and why the whole model is flicker-free
51
- without damage tracking or clipping.
50
+ own `rect`" contract, the bound that enforces it, and why the whole
51
+ model is flicker-free without damage tracking.
52
52
  3. **[Layout: the parent sets the size](03-layout.md).** The heart of
53
- the design. Top-down, absolute, integer coordinates; a parent
53
+ the design. Top-down, parent-relative, integer coordinates; a parent
54
54
  assigns its children's `rect` and components never negotiate a size.
55
55
  The C64 argument for *why simple layouting is enough* on a character
56
- grid, `Layout::Absolute` and the `rect=` override, the `Vertical` /
56
+ grid, the `relayout` override, `Layout::Absolute`, the `Vertical` /
57
57
  `Horizontal` box layouts and their three constraints (`Fixed` /
58
58
  `Percent` / `Expand`) as sugar over that same rule, `Fraction` for
59
59
  sizing a popup against the screen, and resize as a discrete
@@ -40,18 +40,26 @@ module FileCommanderExample
40
40
  # navigation, and notifies a callback so the shared header label can be
41
41
  # rebuilt without the panes knowing about each other.
42
42
  class DirList < Tuile::Component::List
43
+ # What `on_cwd_changed` fires. An app's own event is a `Data.define`
44
+ # including the marker, exactly as the gem's are.
45
+ CwdChangedEvent = Data.define(:source, :cwd) { include Tuile::Event }
46
+
47
+ # @!method on_cwd_changed
48
+ # Fired whenever this pane's `cwd` changes, or it takes focus — the shared
49
+ # header rebuilds from it, so the two panes need not know about each other.
50
+ # @return [Tuile::Listeners]
51
+ listener :on_cwd_changed
52
+
43
53
  def initialize(start_dir)
44
54
  super()
45
55
  self.cursor = Tuile::Component::List::Cursor.new
46
- self.renderer = ->(entry) { Rainbow(entry[:display]).color(TYPE_COLORS[entry[:type]]) }
56
+ self.renderer = ->(entry, _w) { Rainbow(entry[:display]).color(TYPE_COLORS[entry[:type]]) }
47
57
  @cwd = File.expand_path(start_dir)
48
- @on_cwd_changed = nil
49
58
  load_entries
50
- self.on_item_chosen = method(:descend)
59
+ on_item_chosen << method(:descend)
51
60
  end
52
61
 
53
62
  attr_reader :cwd
54
- attr_accessor :on_cwd_changed
55
63
 
56
64
  def handle_key?(key)
57
65
  return false unless active?
@@ -66,13 +74,13 @@ module FileCommanderExample
66
74
 
67
75
  def handle_focus
68
76
  super
69
- @on_cwd_changed&.call
77
+ fire_cwd_changed
70
78
  end
71
79
 
72
80
  private
73
81
 
74
- def descend(_index, entry)
75
- target = File.expand_path(File.join(@cwd, entry[:name]))
82
+ def descend(event)
83
+ target = File.expand_path(File.join(@cwd, event.item[:name]))
76
84
  change_to(target) if File.directory?(target)
77
85
  end
78
86
 
@@ -81,13 +89,15 @@ module FileCommanderExample
81
89
  change_to(parent) if parent != @cwd
82
90
  end
83
91
 
92
+ def fire_cwd_changed = on_cwd_changed.fire(CwdChangedEvent.new(source: self, cwd: @cwd))
93
+
84
94
  def change_to(path)
85
95
  previous = @cwd
86
96
  @cwd = path
87
97
  load_entries
88
98
  self.cursor = Tuile::Component::List::Cursor.new
89
99
  self.scroll_top_row = 0
90
- @on_cwd_changed&.call
100
+ fire_cwd_changed
91
101
  rescue SystemCallError => e
92
102
  @cwd = previous
93
103
  Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
@@ -118,9 +128,9 @@ module FileCommanderExample
118
128
  end
119
129
 
120
130
  # Top-level layout. Header label on the first row, two side-by-side
121
- # windows below. `rect=` re-runs on the initial mount and on every WINCH,
122
- # so the split tracks the terminal size automatically.
123
- class FileCommander < Tuile::Component::Layout::Absolute
131
+ # windows below. `relayout` re-runs on the initial mount and on every
132
+ # WINCH, so the split tracks the terminal size automatically.
133
+ class FileCommander < Tuile::Component::Layout
124
134
  def initialize(left_dir, right_dir)
125
135
  super()
126
136
  @header = Tuile::Component::Label.new
@@ -128,21 +138,21 @@ module FileCommanderExample
128
138
 
129
139
  @left_window = Tuile::Component::Window.new
130
140
  @left_list = DirList.new(left_dir)
131
- @left_list.on_cwd_changed = method(:refresh_header)
141
+ @left_list.on_cwd_changed << method(:refresh_header)
132
142
  @left_window.content = @left_list
133
143
  @left_window.scrollbar = true
134
144
  add(@left_window)
135
145
 
136
146
  @right_window = Tuile::Component::Window.new
137
147
  @right_list = DirList.new(right_dir)
138
- @right_list.on_cwd_changed = method(:refresh_header)
148
+ @right_list.on_cwd_changed << method(:refresh_header)
139
149
  @right_window.content = @right_list
140
150
  @right_window.scrollbar = true
141
151
  add(@right_window)
142
152
 
143
153
  # The status line. Every key here works in both panes, so the row never
144
154
  # changes and nothing needs to watch focus — a status line is only worth
145
- # wiring to Tuile::Screen#on_focus_changed= when its text actually varies
155
+ # wiring to Tuile::Screen#on_focus_changed when its text actually varies
146
156
  # with the focused component. `theme.fg` bakes its colors in, so the
147
157
  # one thing this label does watch is a light/dark flip.
148
158
  @status = Tuile::Component::Label.new
@@ -152,24 +162,25 @@ module FileCommanderExample
152
162
  "Enter #{t.fg(:hint, "Open")} Bksp #{t.fg(:hint, "Up")}"
153
163
  end
154
164
  render_status.call
155
- @status.on_theme_changed = render_status
165
+ @status.on_theme_changed << render_status
156
166
  add(@status)
157
167
  end
158
168
 
159
169
  attr_reader :left_window
160
170
 
161
- def rect=(new_rect)
162
- super
163
- return if rect.empty?
164
-
165
- @header.rect = Tuile::Rect.new(rect.left, rect.top, rect.width, 1)
166
- @status.rect = Tuile::Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
167
- body_top = rect.top + 1
168
- body_height = [rect.height - 2, 0].max
169
- half = rect.width / 2
170
- @left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
171
- @right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
172
- rect.width - half, body_height)
171
+ protected
172
+
173
+ # No `return if rect.empty?` guard: an empty pane still assigns every
174
+ # child, or they strand at their old coordinates (`D_empty_ancestor`).
175
+ def relayout
176
+ # A child's rect is relative to this layout, so nothing here names where
177
+ # the layout itself sits — moving it moves the whole pane for free.
178
+ @header.rect = Tuile::Rect.new(0, 0, width, 1)
179
+ @status.rect = Tuile::Rect.new(0, height - 1, width, 1)
180
+ body_height = [height - 2, 0].max
181
+ half = width / 2
182
+ @left_window.rect = Tuile::Rect.new(0, 1, half, body_height)
183
+ @right_window.rect = Tuile::Rect.new(half, 1, width - half, body_height)
173
184
  end
174
185
 
175
186
  private
@@ -37,7 +37,7 @@ window.content = Tuile::Component::Label.new("Hello, world!")
37
37
  status = Tuile::Component::Label.new
38
38
  render_status = -> { status.text = "q #{screen.theme.fg(:hint, "quit")}" }
39
39
  render_status.call
40
- status.on_theme_changed = render_status
40
+ status.on_theme_changed << render_status
41
41
 
42
42
  # One row for the status line, everything else to the window.
43
43
  root = Tuile::Component::Layout::Vertical.new