tuile 0.10.0 → 0.12.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -64
  3. data/DECISIONS.md +1299 -22
  4. data/README.md +18 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +154 -9
  8. data/book/05-focus.md +2 -0
  9. data/book/06-theming.md +1 -1
  10. data/book/07-components.md +202 -37
  11. data/book/README.md +3 -1
  12. data/examples/file_commander.rb +5 -4
  13. data/examples/sampler.rb +320 -133
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +26 -12
  16. data/lib/tuile/buffer.rb +7 -7
  17. data/lib/tuile/component/big_decimal_field.rb +199 -0
  18. data/lib/tuile/component/button.rb +1 -1
  19. data/lib/tuile/component/checkbox.rb +11 -10
  20. data/lib/tuile/component/checkbox_group.rb +31 -26
  21. data/lib/tuile/component/combo_box.rb +18 -33
  22. data/lib/tuile/component/float_field.rb +161 -0
  23. data/lib/tuile/component/info_window.rb +1 -1
  24. data/lib/tuile/component/label.rb +14 -14
  25. data/lib/tuile/component/layout/box.rb +316 -0
  26. data/lib/tuile/component/layout/horizontal.rb +40 -0
  27. data/lib/tuile/component/layout/vertical.rb +41 -0
  28. data/lib/tuile/component/layout.rb +149 -1
  29. data/lib/tuile/component/list.rb +291 -216
  30. data/lib/tuile/component/list_dropdown.rb +82 -24
  31. data/lib/tuile/component/notification.rb +317 -0
  32. data/lib/tuile/component/picker_window.rb +3 -3
  33. data/lib/tuile/component/popup.rb +8 -10
  34. data/lib/tuile/component/progress_bar.rb +1 -1
  35. data/lib/tuile/component/radio_group.rb +32 -30
  36. data/lib/tuile/component/select.rb +251 -0
  37. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  38. data/lib/tuile/component/text_area.rb +79 -273
  39. data/lib/tuile/component/text_field.rb +1 -1
  40. data/lib/tuile/component/text_view.rb +191 -177
  41. data/lib/tuile/component/window.rb +8 -8
  42. data/lib/tuile/component.rb +5 -5
  43. data/lib/tuile/screen.rb +1 -1
  44. data/lib/tuile/styled_string.rb +25 -15
  45. data/lib/tuile/version.rb +1 -1
  46. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  47. data/lib/tuile.rb +4 -0
  48. data/sig/tuile.rbs +1670 -406
  49. metadata +11 -1
@@ -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,8 @@ module Tuile
52
52
  self.content = field
53
53
 
54
54
  @overlay = ListDropdown.new
55
- @overlay.on_item_chosen = ->(index, _line) { commit(index) }
55
+ @overlay.renderer = ->(item) { @item_label.call(item) }
56
+ @overlay.on_item_chosen = ->(_index, item) { commit(item) }
56
57
  end
57
58
 
58
59
  # @return [Array] the candidate items.
@@ -147,11 +148,12 @@ module Tuile
147
148
  protected
148
149
 
149
150
  # Field spans the row bar the last column, which the `▾` occupies
150
- # ({HasContent} layout hook).
151
+ # ({HasContent} layout hook). One row, or none at all when the combo itself
152
+ # was given none — a starved parent must not hand out a rect it doesn't own.
151
153
  # @param field [Component]
152
154
  # @return [void]
153
155
  def layout(field)
154
- field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, 1)
156
+ field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
155
157
  end
156
158
 
157
159
  private
@@ -194,7 +196,7 @@ module Tuile
194
196
  if @filtered.empty?
195
197
  close_menu
196
198
  else
197
- @overlay.lines = @filtered.map { |item| @item_label.call(item) }
199
+ @overlay.items = @filtered
198
200
  @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0)
199
201
  @overlay.open unless @overlay.open?
200
202
  anchor
@@ -213,12 +215,11 @@ module Tuile
213
215
  @items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) }
214
216
  end
215
217
 
216
- # Commits the item at the menu's `index`: closes the dropdown and adopts
217
- # it as {#value} (which repaints the field with its label).
218
- # @param index [Integer]
218
+ # Commits the item chosen from the menu: closes the dropdown and adopts it
219
+ # as {#value} (which repaints the field with its label).
220
+ # @param item [Object]
219
221
  # @return [void]
220
- def commit(index)
221
- item = @filtered[index]
222
+ def commit(item)
222
223
  close_menu
223
224
  self.value = item
224
225
  end
@@ -234,6 +235,9 @@ module Tuile
234
235
 
235
236
  # Sets the field's text without triggering a refilter — for programmatic
236
237
  # value changes and query reverts, which must not spring the dropdown.
238
+ # Every programmatic write to the field goes through here; a direct
239
+ # `content.text =` reaches the field's `on_change` and pops the dropdown
240
+ # open on a {#value=} the user never asked to browse.
237
241
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
238
242
  # shorter query replaced by a longer label would otherwise strand it
239
243
  # mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
@@ -251,31 +255,12 @@ module Tuile
251
255
  # @return [String] the plain-text label for `item`, or "" for nil.
252
256
  def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s
253
257
 
254
- # Sizes and positions the dropdown against the field: full combo width,
255
- # `min(matches, 10)` rows, below the field — flipped above when it won't
256
- # fit beneath, clamped (with the list scrolling) when it fits neither.
258
+ # Places the dropdown at the combo's own width, so both its edges line up
259
+ # with the field — at the cost of the scrollbar taking its column from the
260
+ # labels, which ellipsize a column earlier once the list scrolls. That is
261
+ # the trade a measuring driver ({Select}) makes the other way.
257
262
  # @return [void]
258
- def anchor
259
- desired = [@filtered.size, MAX_VISIBLE_ROWS].min
260
- below = screen.size.height - (rect.top + 1)
261
- above = rect.top
262
- if desired <= below
263
- top = rect.top + 1
264
- height = desired
265
- elsif above >= below
266
- height = [desired, above].min
267
- top = rect.top - height
268
- else
269
- height = below
270
- top = rect.top + 1
271
- end
272
- @overlay.size = Size.new(rect.width, height)
273
- @overlay.rect = Rect.new(rect.left, top, rect.width, height)
274
- end
275
-
276
- # Most matches shown before the dropdown scrolls.
277
- # @return [Integer]
278
- MAX_VISIBLE_ROWS = 10
263
+ def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
279
264
  end
280
265
  end
281
266
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A single-line field whose {#value} is a `Float` (or `nil` when empty) —
6
+ # the {IntegerField} twin, one Ruby type over. Give it a single-row {#rect}:
7
+ #
8
+ # field = Component::FloatField.new
9
+ # field.on_value_change = ->(x) { puts x.inspect } # Float or nil, per change
10
+ # field.value = 19.99 # field shows "19.99"
11
+ # field.clear # empties it; value => nil
12
+ #
13
+ # Only `0`–`9`, one leading `-` and one `.` can be typed; any other
14
+ # printable key is dropped without moving the caret. Up/Down step by `1.0`
15
+ # (an empty field counting as `0.0`). A `Float` is a binary double, so this
16
+ # is the wrong field for money — hold that as `Integer` cents in an
17
+ # {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
18
+ # not here.
19
+ #
20
+ # == Implementation details
21
+ # {#value} is a *derived parse*: the buffer is the single source of truth,
22
+ # recomputed on read and left exactly as typed (`"007"` keeps its zeros).
23
+ # It reads `nil` for a buffer that isn't a number (`""`, a lone `"-"`) but
24
+ # `1.0` / `0.5` for a half-typed `"1."` / `".5"`, so reaching for the
25
+ # decimal point doesn't blink the value to `nil` and back through
26
+ # {#on_value_change} — which fires per keystroke, but only on a real *value*
27
+ # change (`"7"`→`"07"` is silent). The parse also accepts the exponent
28
+ # `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
29
+ # through the `"1.0e-05"` it displays, though no key types an `e`.
30
+ #
31
+ # It *composes* a {TextField} (its single {HasContent} child) rather than
32
+ # subclassing one, so its face carries only the typed {HasValue} seam, never
33
+ # the widget's `String`-typed `text`.
34
+ #
35
+ # UI-thread-confined, like every component (see {Screen}).
36
+ class FloatField < Component
37
+ include HasContent
38
+ include HasValue
39
+
40
+ # A buffer {#value} parses: an optional sign, digits with an optional
41
+ # fractional part (either side may be empty, but not both), and the
42
+ # exponent {#value=} can write.
43
+ # @return [Regexp]
44
+ NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
45
+ private_constant :NUMERIC
46
+
47
+ def initialize
48
+ super()
49
+ @last_value = nil
50
+ field = TextField.new
51
+ field.on_change = ->(_text) { fire_if_changed }
52
+ field.on_key = method(:field_key)
53
+ self.content = field
54
+ end
55
+
56
+ # @return [Float, nil] the parsed buffer; `nil` when empty or not a
57
+ # number (e.g. a lone `"-"`).
58
+ def value
59
+ text = content.text
60
+ text.match?(NUMERIC) ? text.to_f : nil
61
+ end
62
+
63
+ # Writes `new_value` into the buffer and parks the caret at its end; fires
64
+ # {#on_value_change} only if the value actually changed.
65
+ # @param new_value [Numeric, nil] `nil` empties the field; anything else
66
+ # is coerced with `Float()`, so an `Integer` `3` shows as `"3.0"`.
67
+ # @raise [ArgumentError] on a non-numeric `String`, a NaN or an infinity.
68
+ # @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
69
+ # @return [void]
70
+ def value=(new_value)
71
+ content.text = new_value.nil? ? "" : coerce(new_value).to_s
72
+ content.caret = content.text.length
73
+ end
74
+
75
+ # `nil`, not `""`: a numeric field with no parseable number is empty.
76
+ # @return [nil]
77
+ def empty_value = nil
78
+
79
+ # @return [Point, nil] the field's caret (the hardware cursor is delegated
80
+ # to the inner field).
81
+ def cursor_position = content.cursor_position
82
+
83
+ # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
84
+ # @return [Proc, Method, nil] no-arg callable, or nil.
85
+ def on_enter = content.on_enter
86
+
87
+ # @param callback [Proc, Method, nil]
88
+ # @return [void]
89
+ def on_enter=(callback)
90
+ content.on_enter = callback
91
+ end
92
+
93
+ protected
94
+
95
+ # Places the wrapped field across the whole rect ({HasContent} hook).
96
+ # @param field [Component]
97
+ # @return [void]
98
+ def layout(field) = (field.rect = rect)
99
+
100
+ private
101
+
102
+ # @param new_value [Numeric]
103
+ # @return [Float]
104
+ # @raise [ArgumentError] on a NaN or an infinity — `Float::NAN.to_s` is
105
+ # `"NaN"`, which no parse reads back, so writing one would silently
106
+ # turn the value `nil`.
107
+ def coerce(new_value)
108
+ float = Float(new_value)
109
+ raise ArgumentError, "value must be finite, got #{float}" unless float.finite?
110
+
111
+ float
112
+ end
113
+
114
+ # The field's key interceptor, consulted *before* the field acts on the
115
+ # key — which is what lets a rejected character be swallowed without the
116
+ # caret ever moving.
117
+ # @param key [String]
118
+ # @return [Boolean] true to consume the key.
119
+ def field_key(key)
120
+ case key
121
+ when Keys::UP_ARROW then step(1.0)
122
+ when Keys::DOWN_ARROW then step(-1.0)
123
+ else return Keys.printable?(key) && !accepts?(key)
124
+ end
125
+ true
126
+ end
127
+
128
+ # Nudges {#value} by `delta`, treating an empty/un-parseable field as
129
+ # `0.0`.
130
+ # @param delta [Float]
131
+ # @return [void]
132
+ def step(delta) = (self.value = (value || 0.0) + delta)
133
+
134
+ # Whether `char` may be inserted. Deliberately shallow: it keeps the
135
+ # buffer *typeable* rather than always-valid — a transient `"-"` or
136
+ # `"1."` has to be reachable — and {#value} decides what parses.
137
+ # @param char [String] a single printable character.
138
+ # @return [Boolean]
139
+ def accepts?(char)
140
+ case char
141
+ when /\A[0-9]\z/ then true
142
+ when "-" then content.caret.zero? && !content.text.start_with?("-")
143
+ when "." then !content.text.include?(".")
144
+ else false
145
+ end
146
+ end
147
+
148
+ # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
149
+ # when it differs from the last one fired — so a buffer edit that leaves
150
+ # the value unchanged (`"7"`→`"07"`) stays silent.
151
+ # @return [void]
152
+ def fire_if_changed
153
+ v = value
154
+ return if v == @last_value
155
+
156
+ @last_value = v
157
+ on_value_change&.call(v)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -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]