tuile 0.11.0 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
data/lib/tuile/ansi.rb CHANGED
@@ -12,6 +12,16 @@ module Tuile
12
12
  # @return [String]
13
13
  RESET = "\e[0m"
14
14
 
15
+ # The bell (`BEL`, `\a`, 0x07) — the "that keystroke went nowhere" signal.
16
+ # Ring it with {Screen#beep} rather than printing it: the bell is terminal
17
+ # IO, which is {Screen}'s job.
18
+ #
19
+ # What the user gets is the *terminal's* business — an audible beep, a
20
+ # visual flash, or nothing at all — and Tuile keeps no preference of its
21
+ # own about that.
22
+ # @return [String]
23
+ BEL = "\a"
24
+
15
25
  # Begin Synchronized Update (DEC private mode 2026, "Synchronized
16
26
  # Output"). The terminal stops refreshing its display and buffers every
17
27
  # subsequent write until {SYNC_END}, then composites the whole batch
data/lib/tuile/buffer.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Tuile
4
4
  # An in-memory grid of styled cells mirroring the terminal screen. This is
5
5
  # the back buffer behind flicker-free rendering: components paint into it
6
- # (via {#set_line} / {#set_char} / {#fill}) instead of writing escape
6
+ # (via {#set_text} / {#set_char} / {#fill}) instead of writing escape
7
7
  # sequences straight to the terminal, and {#flush} emits the minimal escape
8
8
  # string needed to bring a terminal — one that already matches the buffer's
9
9
  # state as of the previous flush — up to date. Only cells that actually
@@ -133,7 +133,7 @@ module Tuile
133
133
  # @param x [Integer] column.
134
134
  # @param y [Integer] row.
135
135
  # @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
136
- # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
136
+ # {#set_char} / {#set_text} so dirty tracking stays correct), or nil when
137
137
  # out of bounds.
138
138
  def cell(x, y)
139
139
  return nil unless in_bounds?(x, y)
@@ -160,12 +160,12 @@ module Tuile
160
160
 
161
161
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
162
162
  # display width and clipping at the right edge. Newlines are not handled —
163
- # pass one physical line.
163
+ # pass the text of one row.
164
164
  # @param x [Integer] starting column.
165
165
  # @param y [Integer] row.
166
166
  # @param styled [StyledString]
167
167
  # @return [void]
168
- def set_line(x, y, styled)
168
+ def set_text(x, y, styled)
169
169
  col = x
170
170
  styled.spans.each do |span|
171
171
  span.text.grapheme_clusters.each do |g|
@@ -281,7 +281,7 @@ module Tuile
281
281
  # @param y [Integer] row.
282
282
  # @return [String] row `y` rendered to ANSI across its full width — the
283
283
  # minimal-SGR encoding of its cells, equivalent to what a component's
284
- # `set_line` of the whole row would have printed. Intended for tests that
284
+ # `set_text` of the whole row would have printed. Intended for tests that
285
285
  # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
286
286
  def row_ansi(y)
287
287
  return "" unless y >= 0 && y < @height
@@ -304,7 +304,7 @@ module Tuile
304
304
 
305
305
  # @param rect [Rect]
306
306
  # @return [Array<String>] each row within `rect` rendered to ANSI, top to
307
- # bottom — byte-identical to what a component's per-row `set_line` over
307
+ # bottom — byte-identical to what a component's per-row `set_text` over
308
308
  # that rect emitted. The region equivalent of {#row_ansi}. Intended for
309
309
  # tests asserting styled output.
310
310
  def region_ansi(rect)
@@ -316,7 +316,7 @@ module Tuile
316
316
  private
317
317
 
318
318
  # Core of {#set_char} with the grapheme's display width already known.
319
- # {#set_line} computes each width once while advancing the column and passes
319
+ # {#set_text} computes each width once while advancing the column and passes
320
320
  # it straight through, so the paint hot path measures every grapheme exactly
321
321
  # once (and that once is a {.display_width} memo read). See {#set_char} for
322
322
  # the wide-glyph / clipping / out-of-bounds contract.
@@ -42,6 +42,8 @@ module Tuile
42
42
  #
43
43
  # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
44
44
  # fit `rect.width - 1`).
45
+ # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
46
+ # whole clipboard at the caret in one mutation.
45
47
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
46
48
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
47
49
  # keep the caret visible).
@@ -155,8 +157,42 @@ module Tuile
155
157
  handle_text_input_key(key)
156
158
  end
157
159
 
160
+ # Inserts pasted text at the caret as **one** mutation, so {#on_change}
161
+ # fires once for the whole paste rather than once per character.
162
+ # {#preprocess_paste} filters it first.
163
+ # @param text [String]
164
+ # @return [Boolean] always true — a field consumes every paste, an empty
165
+ # one included.
166
+ def handle_paste(text)
167
+ insert_text(preprocess_paste(text))
168
+ true
169
+ end
170
+
158
171
  protected
159
172
 
173
+ # Input filter for {#handle_paste}, the paste-side counterpart of
174
+ # {#preprocess_text}. Strips the C0 control characters a text buffer
175
+ # cannot hold — a raw `\e` or `\t` reaching {Buffer} would move the real
176
+ # terminal cursor mid-frame — keeping `\n`, and turning a tab into a
177
+ # single space so pasted code keeps its word gaps. {TextField} narrows it
178
+ # further; an app wanting tab *expansion* overrides {#handle_paste}.
179
+ # @param text [String]
180
+ # @return [String]
181
+ def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "")
182
+
183
+ # Inserts `str` at the caret, leaving the caret behind it. The bulk
184
+ # counterpart of a subclass's per-key insert.
185
+ # @param str [String]
186
+ # @return [Boolean] true if the text changed.
187
+ def insert_text(str)
188
+ return false if str.empty?
189
+
190
+ new_text = @text.dup.insert(@caret, str)
191
+ @caret += str.length
192
+ self.text = new_text
193
+ true
194
+ end
195
+
160
196
  # Renders `text` on the field's background well, looked up from the
161
197
  # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
162
198
  # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
@@ -76,7 +76,7 @@ module Tuile
76
76
 
77
77
  label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
78
78
  label = label.with_bg(screen.theme.active_bg_color) if active?
79
- draw_line(rect.left, rect.top, label)
79
+ draw_text(rect.left, rect.top, label)
80
80
  end
81
81
  end
82
82
  end
@@ -127,7 +127,7 @@ module Tuile
127
127
 
128
128
  label = (StyledString.plain(value ? "[x] " : "[ ] ") + caption).ellipsize(rect.width)
129
129
  label = label.with_bg(screen.theme.active_bg_color) if active?
130
- draw_line(rect.left, rect.top, label)
130
+ draw_text(rect.left, rect.top, label)
131
131
  end
132
132
  end
133
133
  end
@@ -23,12 +23,13 @@ module Tuile
23
23
  # Treat it as *unordered*: it iterates in toggle order, so use
24
24
  # `cg.items & cg.value.to_a` when you need {#items} order.
25
25
  #
26
- # Composes rather than subclasses, like {ComboBox}: a {List} is its single
27
- # {HasContent} child, which is where the cursor, scrolling, the scrollbar and
28
- # per-row mouse hit-testing come from. `content` is that list, so an app can
29
- # tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
30
- # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
31
- # group.
26
+ # Composes rather than subclasses, like {ComboBox}: a {List} of the items is
27
+ # its single {HasContent} child, which is where the cursor, scrolling, the
28
+ # scrollbar and per-row mouse hit-testing come from — the group only supplies
29
+ # the {List#renderer} that puts the box in front of the label. `content` is
30
+ # that list, so an app can tune it (`scrollbar_visibility`,
31
+ # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
32
+ # inner list is the tab stop, not the group.
32
33
  #
33
34
  # == +items+ is chrome; +value+ is authoritative
34
35
  # {#items=} changes only what is *presented*. It never touches {#value} and
@@ -46,7 +47,7 @@ module Tuile
46
47
  # mutated after being selected becomes unfindable. Two `==`-equal items also
47
48
  # share one selection — their rows check and uncheck together — whereas two
48
49
  # *distinct* items that merely render the same label toggle independently,
49
- # because a row resolves to an item by index.
50
+ # because a row resolves to its own item, never to its label.
50
51
  #
51
52
  # Rows repeat {Checkbox}'s `[x] `/`[ ] ` glyph convention rather than
52
53
  # importing a constant from it.
@@ -67,7 +68,6 @@ module Tuile
67
68
  # matter to a form helper.
68
69
  def initialize(items: [], value: nil)
69
70
  super()
70
- @items = items.to_a
71
71
  @item_label = :to_s.to_proc
72
72
  @value = coerce(value)
73
73
  @on_value_change = nil
@@ -75,13 +75,14 @@ module Tuile
75
75
  list = List.new
76
76
  # A List has no cursor at all by default (Cursor::None, position -1).
77
77
  list.cursor = List::Cursor.new
78
- list.on_item_chosen = ->(index, _line) { toggle_at(index) }
78
+ list.renderer = method(:render_row)
79
+ list.on_item_chosen = ->(_index, item) { toggle(item) }
80
+ list.items = items.to_a
79
81
  self.content = list
80
- rebuild_rows
81
82
  end
82
83
 
83
84
  # @return [Array] the presented items.
84
- attr_reader :items
85
+ def items = content.items
85
86
 
86
87
  # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
87
88
  # anything with `#to_s`); `:to_s` by default.
@@ -92,17 +93,14 @@ module Tuile
92
93
  # @raise [TypeError] unless `new_items` is an `Array`.
93
94
  # @return [void]
94
95
  def items=(new_items)
95
- raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
96
-
97
- @items = new_items
98
- rebuild_rows
96
+ content.items = new_items
99
97
  end
100
98
 
101
99
  # @param proc [Proc, Method] item -> row label.
102
100
  # @return [void]
103
101
  def item_label=(proc)
104
102
  @item_label = proc
105
- rebuild_rows
103
+ content.refresh_rows
106
104
  end
107
105
 
108
106
  # @return [Set] the frozen empty set — {HasValue#empty?} means nothing is
@@ -122,7 +120,7 @@ module Tuile
122
120
  return if value == selected
123
121
 
124
122
  super(selected)
125
- rebuild_rows
123
+ content.refresh_rows
126
124
  end
127
125
 
128
126
  # Toggles the cursor row on Space. Nothing else is claimed: the composed
@@ -148,22 +146,29 @@ module Tuile
148
146
  private
149
147
 
150
148
  # Flips membership of the item on row `index`; an index outside {#items} is
151
- # ignored.
149
+ # ignored — {List::Cursor::None}'s `-1` would otherwise toggle the *last*
150
+ # item.
152
151
  # @param index [Integer]
153
152
  # @return [void]
154
153
  def toggle_at(index)
155
- return unless index.between?(0, @items.size - 1)
154
+ return unless index.between?(0, items.size - 1)
156
155
 
157
- item = @items[index]
158
- self.value = value.include?(item) ? value - [item] : value + [item]
156
+ toggle(items[index])
159
157
  end
160
158
 
161
- # Re-renders every row from the current items, labels and selection.
159
+ # Flips `item`'s membership of {#value}.
160
+ # @param item [Object]
162
161
  # @return [void]
163
- def rebuild_rows
164
- content.lines = @items.map do |item|
165
- StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
166
- end
162
+ def toggle(item)
163
+ self.value = value.include?(item) ? value - [item] : value + [item]
164
+ end
165
+
166
+ # @param item [Object]
167
+ # @return [StyledString] the item's row: its label behind a checkmark box.
168
+ # The {List} calls this at paint time, so the boxes track {#value}
169
+ # without re-rendering anything but the visible rows.
170
+ def render_row(item)
171
+ StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
167
172
  end
168
173
 
169
174
  # @param new_value [Enumerable, nil]
@@ -52,7 +52,11 @@ module Tuile
52
52
  self.content = field
53
53
 
54
54
  @overlay = ListDropdown.new
55
- @overlay.on_item_chosen = ->(index, _line) { commit(index) }
55
+ # Outside-click dismissal spans the owner chain, so a click on this
56
+ # combo's dropdown must not dismiss a dialog the combo sits in.
57
+ @overlay.owner = self
58
+ @overlay.renderer = ->(item) { @item_label.call(item) }
59
+ @overlay.on_item_chosen = ->(_index, item) { commit(item) }
56
60
  end
57
61
 
58
62
  # @return [Array] the candidate items.
@@ -97,7 +101,6 @@ module Tuile
97
101
  def cursor_position = content.cursor_position
98
102
 
99
103
  # @return [String]
100
- def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
101
104
 
102
105
  # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
103
106
  # field via {#layout}.
@@ -195,7 +198,7 @@ module Tuile
195
198
  if @filtered.empty?
196
199
  close_menu
197
200
  else
198
- @overlay.lines = @filtered.map { |item| @item_label.call(item) }
201
+ @overlay.items = @filtered
199
202
  @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
200
203
  @overlay.open unless @overlay.open?
201
204
  anchor
@@ -214,12 +217,11 @@ module Tuile
214
217
  @items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
215
218
  end
216
219
 
217
- # Commits the item at the menu's `index`: closes the dropdown and adopts
218
- # it as {#value} (which repaints the field with its label).
219
- # @param index [Integer]
220
+ # Commits the item chosen from the menu: closes the dropdown and adopts it
221
+ # as {#value} (which repaints the field with its label).
222
+ # @param item [Object]
220
223
  # @return [void]
221
- def commit(index)
222
- item = @filtered[index]
224
+ def commit(item)
223
225
  close_menu
224
226
  self.value = item
225
227
  end
@@ -235,6 +237,9 @@ module Tuile
235
237
 
236
238
  # Sets the field's text without triggering a refilter — for programmatic
237
239
  # value changes and query reverts, which must not spring the dropdown.
240
+ # Every programmatic write to the field goes through here; a direct
241
+ # `content.text =` reaches the field's `on_change` and pops the dropdown
242
+ # open on a {#value=} the user never asked to browse.
238
243
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
239
244
  # shorter query replaced by a longer label would otherwise strand it
240
245
  # mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
@@ -25,7 +25,7 @@ module Tuile
25
25
  # list wraps and scrolls within it. Defaults to {Fraction::HALF}.
26
26
  # @return [Popup] the opened popup.
27
27
  def self.open(caption, lines, size: Fraction::HALF)
28
- Popup.open(content: InfoWindow.new(caption, lines), size: size)
28
+ Popup.new(content: InfoWindow.new(caption, lines), size: size).open
29
29
  end
30
30
  end
31
31
  end
@@ -16,8 +16,8 @@ module Tuile
16
16
  super()
17
17
  @text = StyledString::EMPTY
18
18
  @bg = nil
19
- @clipped_lines = []
20
- @blank_line = ""
19
+ @rows = []
20
+ @blank_row = ""
21
21
  self.text = text unless text.nil?
22
22
  end
23
23
 
@@ -43,7 +43,7 @@ module Tuile
43
43
  return if @text == new_text
44
44
 
45
45
  @text = new_text
46
- update_clipped_lines
46
+ update_rows
47
47
  invalidate
48
48
  end
49
49
 
@@ -60,7 +60,7 @@ module Tuile
60
60
  return if @bg == new_bg
61
61
 
62
62
  @bg = new_bg
63
- update_clipped_lines
63
+ update_rows
64
64
  invalidate
65
65
  end
66
66
 
@@ -69,15 +69,15 @@ module Tuile
69
69
  # Skips the {Component#repaint} default's auto-clear: every row is
70
70
  # painted explicitly (with pre-padded blanks past the last line), so
71
71
  # the "fully draw over your rect" contract is met without an upfront
72
- # wipe. Rows go through {Component#draw_line}, so the padding and blank
72
+ # wipe. Rows go through {Component#draw_text}, so the padding and blank
73
73
  # rows inherit {Component#effective_bg_color} when {#bg} is unset.
74
74
  # @return [void]
75
75
  def repaint
76
76
  return if rect.empty?
77
77
 
78
78
  (0...rect.height).each do |row|
79
- line = @clipped_lines[row] || @blank_line
80
- draw_line(rect.left, rect.top + row, line)
79
+ line = @rows[row] || @blank_row
80
+ draw_text(rect.left, rect.top + row, line)
81
81
  end
82
82
  end
83
83
 
@@ -86,22 +86,22 @@ module Tuile
86
86
  # @return [void]
87
87
  def on_width_changed
88
88
  super
89
- update_clipped_lines
89
+ update_rows
90
90
  end
91
91
 
92
92
  private
93
93
 
94
- # Recomputes {@clipped_lines} for the current text and rect width.
94
+ # Recomputes {@rows} for the current text and rect width.
95
95
  # Each line is ellipsized to fit and padded with trailing spaces out to
96
- # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
97
- # row. {@blank_line} covers rows past the last text line. When {#bg} is
96
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_text} per
97
+ # row. {@blank_row} covers rows past the last text line. When {#bg} is
98
98
  # set, every produced line (and the blank row) has the bg applied
99
99
  # uniformly.
100
100
  # @return [void]
101
- def update_clipped_lines
101
+ def update_rows
102
102
  width = rect.width.clamp(0, nil)
103
- @blank_line = apply_bg(StyledString.plain(" " * width))
104
- @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
103
+ @blank_row = apply_bg(StyledString.plain(" " * width))
104
+ @rows = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
105
105
  end
106
106
 
107
107
  # @param line [StyledString]