tuile 0.9.0 → 0.10.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 +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row progress bar: a run of `█` growing left to right across {#rect},
6
+ # over a `░` track.
7
+ #
8
+ # ████████░░░░░░░░░░░░
9
+ #
10
+ # bar = Component::ProgressBar.new(range: 0..files.size)
11
+ # label = Component::Label.new
12
+ # add(bar)
13
+ # add(label)
14
+ #
15
+ # def rect=(new_rect) # the enclosing Layout positions both
16
+ # super
17
+ # bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
18
+ # label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
19
+ # end
20
+ #
21
+ # bar.value = done
22
+ # label.text = "#{bar.percent}% — #{done}/#{files.size}"
23
+ #
24
+ # The bar paints no text of its own: put a {Label} beside it and feed it
25
+ # {#percent} or {#fraction}, so the app words it ("42% — 3/7 files") and
26
+ # places it freely. Display-only — not focusable, no keys, no mouse.
27
+ #
28
+ # While the total is still unknown, {#indeterminate=} swaps the fill for a
29
+ # block sliding across the bar:
30
+ #
31
+ # ░░░░░░░████░░░░░░░░░
32
+ #
33
+ # Both endpoints are exact: the bar is full only at {#max} and empty only at
34
+ # {#min}, so a full bar always means done. Assign a one-row {#rect}; a taller
35
+ # one paints the bar on its first row and leaves the rest to the background.
36
+ #
37
+ # == Implementation details
38
+ # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
+ # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
+ # the rendered length would vary with the fill level. Shipped anyway, per
41
+ # `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
42
+ # beats a third convention, and if that bet is ever reversed both swap
43
+ # together.
44
+ class ProgressBar < Component
45
+ # Range covering the whole bar when none is given.
46
+ # @return [Range]
47
+ DEFAULT_RANGE = (0.0..1.0)
48
+
49
+ # Frames per second of the indeterminate animation. The block advances one
50
+ # cell per frame, so this is also its speed in cells/second.
51
+ # @return [Integer]
52
+ INDETERMINATE_FPS = 5
53
+
54
+ # The indeterminate block is this fraction of the bar, at least one cell.
55
+ # @return [Integer]
56
+ BLOCK_DIVISOR = 5
57
+
58
+ # @param range [Range] initial {#range=}.
59
+ # @param value [Numeric, nil] initial {#value=}; `nil` starts at the range's
60
+ # lower bound.
61
+ # @param indeterminate [Boolean] initial {#indeterminate=}.
62
+ def initialize(range: DEFAULT_RANGE, value: nil, indeterminate: false)
63
+ super()
64
+ @value = 0.0
65
+ @min = 0.0
66
+ @max = 1.0
67
+ @phase = 0
68
+ @ticker = nil
69
+ @indeterminate = false
70
+ @bar_color = nil
71
+ self.range = range
72
+ self.value = value unless value.nil?
73
+ self.indeterminate = indeterminate
74
+ end
75
+
76
+ # @return [Float] lower bound of {#range}.
77
+ attr_reader :min
78
+
79
+ # @return [Float] upper bound of {#range}.
80
+ attr_reader :max
81
+
82
+ # @return [Color, nil] the value as set, so a {Theme::Ref} comes back
83
+ # unresolved. Both glyphs paint in it; `nil` (the default) is the
84
+ # terminal's default foreground.
85
+ attr_reader :bar_color
86
+
87
+ # @return [Range] the scale {#value} is measured against.
88
+ def range = @min..@max
89
+
90
+ # Replaces the scale, re-clamping {#value} into it. `min == max` is legal
91
+ # and reads as complete — a zero-length job has nothing outstanding — so
92
+ # `bar.range = 0..files.size` needs no special case for an empty list.
93
+ #
94
+ # @param new_range [Range] inclusive; endpoints Numeric and finite.
95
+ # @return [void]
96
+ # @raise [ArgumentError] on an exclusive, inverted or non-finite range, or
97
+ # an endpoint `Float()` cannot parse.
98
+ # @raise [TypeError] on a beginless or endless range — its `nil` endpoint
99
+ # is what `Float()` refuses — or any other type it refuses outright.
100
+ def range=(new_range)
101
+ raise ArgumentError, "range must be inclusive, got #{new_range.inspect}" if new_range.exclude_end?
102
+
103
+ min = Float(new_range.begin)
104
+ max = Float(new_range.end)
105
+ raise ArgumentError, "range end #{max} is below its start #{min}" if max < min
106
+
107
+ unless min.finite? && max.finite?
108
+ raise ArgumentError, "range endpoints must be finite (use indeterminate = true)"
109
+ end
110
+
111
+ @min = min
112
+ @max = max
113
+ self.value = @value
114
+ invalidate # the scale moved even when the clamped value did not
115
+ end
116
+
117
+ # @return [Float] the progress, clamped into {#range} when assigned — so
118
+ # `bar.value = 999` on a `0..250` bar reads back as `250.0`.
119
+ attr_reader :value
120
+
121
+ # @param new_value [Numeric] clamped into {#range}.
122
+ # @return [void]
123
+ # @raise [ArgumentError] on NaN — typically `done.to_f / total` with a zero
124
+ # total, which wants {#indeterminate=} instead — or on a String `Float()`
125
+ # cannot parse.
126
+ # @raise [TypeError] on `nil` and other types `Float()` refuses outright.
127
+ def value=(new_value)
128
+ new_value = Float(new_value)
129
+ raise ArgumentError, "value must be a number, got NaN" if new_value.nan?
130
+
131
+ new_value = new_value.clamp(@min, @max)
132
+ return if @value == new_value
133
+
134
+ @value = new_value
135
+ invalidate
136
+ end
137
+
138
+ # @return [Float] {#value} as `0.0..1.0`. `1.0` when the range is empty.
139
+ def fraction
140
+ return 1.0 if @max == @min
141
+
142
+ (@value - @min) / (@max - @min)
143
+ end
144
+
145
+ # @return [Integer] {#fraction} as `0..100`, floored — `100` means done and
146
+ # nothing else does, matching the painted bar exactly.
147
+ def percent = scale(100)
148
+
149
+ # Sets the color of both glyphs, live-resolved at paint time when given a
150
+ # {Theme::Ref} (so it follows a {Screen#theme=} with no
151
+ # {Component#on_theme_changed} hook).
152
+ #
153
+ # bar.bar_color = Color::GREEN
154
+ # bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
155
+ #
156
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
157
+ # coerced via {Color.coerce} unless it is a {Theme::Ref}; `nil` is the
158
+ # terminal default.
159
+ # @return [void]
160
+ # @raise [KeyError] when a {Theme::Ref} names a token the current theme
161
+ # lacks.
162
+ def bar_color=(color)
163
+ color = Color.coerce(color) unless color.is_a?(Theme::Ref)
164
+ return if @bar_color == color
165
+
166
+ color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
167
+
168
+ @bar_color = color
169
+ invalidate
170
+ end
171
+
172
+ # @return [Boolean] whether the sliding-block animation is showing.
173
+ def indeterminate? = @indeterminate
174
+
175
+ # Switches between the fill and the sliding block. {#value} keeps working
176
+ # while indeterminate — it is simply not painted — so switching back shows
177
+ # the progress that accumulated meanwhile.
178
+ #
179
+ # The animation only runs while the bar is {Component#attached? attached},
180
+ # and stops on detach. It also keeps the event loop awake at
181
+ # {INDETERMINATE_FPS}, so turn it off (or remove the bar) when the job ends.
182
+ #
183
+ # @param flag [Boolean] coerced; truthiness decides.
184
+ # @return [void]
185
+ def indeterminate=(flag)
186
+ flag = flag ? true : false
187
+ return if @indeterminate == flag
188
+
189
+ @indeterminate = flag
190
+ sync_ticker
191
+ invalidate # the picture changes now, not on the next frame
192
+ end
193
+
194
+ # @return [void]
195
+ def on_attached = sync_ticker
196
+
197
+ # @return [void]
198
+ def on_detached = sync_ticker
199
+
200
+ # Paints the bar on the first row of {#rect} and blanks the rest.
201
+ #
202
+ # Deliberately not `super`: {Component#repaint}'s default blanks the
203
+ # *whole* rect, which dirties every cell of the bar's own row before it is
204
+ # painted over — so {Buffer#flush} re-emits the entire row every frame
205
+ # instead of the one or two cells that actually moved.
206
+ # @return [void]
207
+ def repaint
208
+ return if rect.empty?
209
+
210
+ draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
211
+ clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
212
+ end
213
+
214
+ private
215
+
216
+ # Filled cells out of `steps` — the rect width when painting, 100 for
217
+ # {#percent}, so the bar and a {Label} showing the percentage can never
218
+ # disagree about being done.
219
+ # @param steps [Integer]
220
+ # @return [Integer]
221
+ def scale(steps)
222
+ return 0 if fraction <= 0.0
223
+ return steps if fraction >= 1.0
224
+ return 0 if steps < 2 # no interior to land in; fills only when done
225
+
226
+ (fraction * steps).floor.clamp(1, steps - 1)
227
+ end
228
+
229
+ # @param width [Integer] columns available.
230
+ # @return [String] the row, `width` glyphs wide.
231
+ def glyphs(width)
232
+ start, length = @indeterminate ? block_at(width) : [0, scale(width)]
233
+ [("░" * start), ("█" * length), ("░" * (width - start - length))].join
234
+ end
235
+
236
+ # Where the sliding block sits this frame: it enters at the left edge and
237
+ # leaves at the right, one cell per frame, then loops. The period is one
238
+ # short of `width + block` so at least one cell is always lit — a full
239
+ # `width + block` blanks the bar for exactly one frame per cycle.
240
+ # @param width [Integer] columns available.
241
+ # @return [Array(Integer, Integer)] start column and length, clipped.
242
+ def block_at(width)
243
+ block = [width / BLOCK_DIVISOR, 1].max
244
+ start = (@phase % (width + block - 1)) - (block - 1)
245
+ first = [start, 0].max
246
+ last = [start + block, width].min
247
+ [first, last - first]
248
+ end
249
+
250
+ # @return [Color, nil]
251
+ def resolved_bar_color
252
+ @bar_color.is_a?(Theme::Ref) ? @bar_color.resolve(screen.theme) : @bar_color
253
+ end
254
+
255
+ # Brings the ticker in line with "animating and on screen". The sole writer
256
+ # of `@ticker`, and idempotent, so the attach/detach hooks and
257
+ # {#indeterminate=} are all the same call and a repeated `indeterminate =
258
+ # true` cannot start a second one.
259
+ # @return [void]
260
+ def sync_ticker
261
+ want = attached? && @indeterminate
262
+ return if want == !@ticker.nil?
263
+
264
+ if want
265
+ @ticker = screen.event_queue.tick_fps(INDETERMINATE_FPS) do |tick|
266
+ # The paint that already happened is frame 0; a ticker's first
267
+ # firing is one interval later, so it is frame 1.
268
+ @phase = tick + 1
269
+ invalidate
270
+ end
271
+ else
272
+ @ticker.cancel
273
+ @ticker = nil
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Single-select from a set of typed items, one row each. Arrows move a
6
+ # cursor; Space, Enter or a left click selects the row under it:
7
+ #
8
+ # (*) Ascending
9
+ # ( ) Descending <- cursor row, highlighted across the full width
10
+ # ( ) Unsorted
11
+ # ^ the composed {List}'s one-column gutter
12
+ #
13
+ # rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
14
+ # rg.value = "Descending" # or seed it via the ctor
15
+ # rg.on_value_change = ->(order) { resort(order) }
16
+ # rg.value # => "Descending"
17
+ # rg.item_label = ->(o) { o.title } # default :to_s
18
+ #
19
+ # {#value} is **the selected item itself** — of whatever type {#items}
20
+ # holds, never its label. `nil` means nothing is selected: that is the
21
+ # initial state, and assigning it is the only way back, since Space on the
22
+ # already-selected row is a no-op rather than a deselect.
23
+ #
24
+ # Composes rather than subclasses, like {ComboBox}: a {List} is its single
25
+ # {HasContent} child, which is where the cursor, scrolling, the scrollbar
26
+ # and per-row mouse hit-testing come from. `content` is that list, so an app
27
+ # can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
28
+ # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
29
+ # group.
30
+ #
31
+ # == The cursor is chrome
32
+ # The cursor and the selection are two independent things, as in
33
+ # {CheckboxGroup} — arrows roam without changing {#value}, so a listener
34
+ # that resorts a pane fires once on intent instead of once per row crossed.
35
+ # {#value=} therefore does *not* move the cursor. An app that wants it
36
+ # parked on the selection parks it:
37
+ #
38
+ # rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
39
+ #
40
+ # {#items=} is the one thing that moves it, clamping it back into range.
41
+ #
42
+ # == +items+ is chrome; +value+ is authoritative
43
+ # {#items=} changes only what is *presented*. It never touches {#value} and
44
+ # never fires {HasValue#on_value_change}, and a selected item absent from
45
+ # {#items} renders no marked row while surviving intact — so a form saved
46
+ # without the user editing anything changes nothing silently. Keeping the
47
+ # two in sync is the app's job. Same contract as {ComboBox#value} and
48
+ # {CheckboxGroup#value}.
49
+ #
50
+ # == Implementation details
51
+ # Two `==`-equal items share one selection, so selecting either marks both
52
+ # rows; two *distinct* items that merely render the same label stay
53
+ # independent, because a row resolves to an item by index.
54
+ #
55
+ # Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
56
+ # than importing constants from it. ASCII deliberately: `(•)` would measure
57
+ # two columns in a terminal configured for East-Asian-Ambiguous glyphs and
58
+ # shift every row's text, which no test would catch.
59
+ #
60
+ # UI-thread-confined, like every component (see {Screen}).
61
+ class RadioGroup < Component
62
+ include HasContent
63
+ include HasValue
64
+
65
+ # @param items [Array] the items to present, one row each; also settable
66
+ # via {#items=}.
67
+ # @param value [Object, nil] the initially selected item. Seeds the
68
+ # backing ivar directly, so no listener fires and assignment order
69
+ # doesn't matter to a form helper.
70
+ def initialize(items: [], value: nil)
71
+ super()
72
+ @items = items.to_a
73
+ @item_label = :to_s.to_proc
74
+ @value = value
75
+ @on_value_change = nil
76
+
77
+ list = List.new
78
+ # A List has no cursor at all by default (Cursor::None, position -1).
79
+ list.cursor = List::Cursor.new
80
+ list.on_item_chosen = ->(index, _line) { select_at(index) }
81
+ self.content = list
82
+ rebuild_rows
83
+ end
84
+
85
+ # @return [Array] the presented items.
86
+ attr_reader :items
87
+
88
+ # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
89
+ # anything with `#to_s`); `:to_s` by default.
90
+ attr_reader :item_label
91
+
92
+ # Replaces the presented rows, leaving {#value} untouched and clamping the
93
+ # cursor back into range.
94
+ # @param new_items [Array]
95
+ # @raise [TypeError] unless `new_items` is an `Array`.
96
+ # @return [void]
97
+ def items=(new_items)
98
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
99
+
100
+ @items = new_items
101
+ # Before the rebuild, so the single {List#on_cursor_changed} that
102
+ # {List#lines=} fires reports the final row rather than a stale one.
103
+ clamp_cursor
104
+ rebuild_rows
105
+ end
106
+
107
+ # @param proc [Proc, Method] item -> row label.
108
+ # @return [void]
109
+ def item_label=(proc)
110
+ @item_label = proc
111
+ rebuild_rows
112
+ end
113
+
114
+ # Selects `new_value`, firing {HasValue#on_value_change} when it really
115
+ # changed. The cursor stays where it is.
116
+ # @param new_value [Object, nil] `nil` selects nothing; an item outside
117
+ # {#items} is kept but renders no marked row.
118
+ # @return [void]
119
+ def value=(new_value)
120
+ # HasValue#value= no-ops on an unchanged value; this guard is what also
121
+ # skips the row rebuild.
122
+ return if value == new_value
123
+
124
+ super
125
+ rebuild_rows
126
+ end
127
+
128
+ # Selects the cursor row on Space. Nothing else is claimed: the composed
129
+ # {List} — being the focused component — has already had its chance at the
130
+ # key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
131
+ # neither of us wants bubbles on to an ancestor.
132
+ # @param key [String]
133
+ # @return [Boolean]
134
+ def handle_key(key)
135
+ return false unless key == " "
136
+
137
+ select_at(content.cursor.position)
138
+ true
139
+ end
140
+
141
+ protected
142
+
143
+ # Places the composed list across the whole rect ({HasContent} hook).
144
+ # @param list [Component]
145
+ # @return [void]
146
+ def layout(list) = (list.rect = rect)
147
+
148
+ private
149
+
150
+ # Selects the item on row `index`; an index outside {#items} is ignored.
151
+ # @param index [Integer]
152
+ # @return [void]
153
+ def select_at(index)
154
+ return unless index.between?(0, @items.size - 1)
155
+
156
+ self.value = @items[index]
157
+ end
158
+
159
+ # Re-renders every row from the current items, labels and selection.
160
+ # @return [void]
161
+ def rebuild_rows
162
+ content.lines = @items.map do |item|
163
+ StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
164
+ end
165
+ end
166
+
167
+ # Pulls an over-range cursor back onto the last row (row 0 when there are
168
+ # none). {List#lines=} leaves a stale cursor alone, which would strand it
169
+ # off-content: no highlight, a dead Enter, and a Space that resolves to
170
+ # `nil` and silently clears the selection.
171
+ # @return [void]
172
+ def clamp_cursor
173
+ cursor = content.cursor
174
+ # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
175
+ # items list floors at 0 instead of going negative.
176
+ cursor.go_to_last(@items.size) if cursor.position >= @items.size
177
+ end
178
+
179
+ # @param item [Object]
180
+ # @return [StyledString, String] whichever {StyledString#+} accepts on the
181
+ # right — so a styled label keeps its spans and a plain one is parsed.
182
+ def label_for(item)
183
+ label = @item_label.call(item)
184
+ label.is_a?(StyledString) ? label : label.to_s
185
+ end
186
+ end
187
+ end
188
+ end