tuile 0.4.0 → 0.6.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.
@@ -19,9 +19,12 @@ module Tuile
19
19
  super()
20
20
  @border_right = 1
21
21
  @caption = caption
22
+ @content = nil
22
23
  # Optional bottom-row chrome that overlays the bottom border (e.g. a
23
24
  # search field).
24
25
  @footer = nil
26
+ @footer_sizing = Sizing::FILL
27
+ update_content_size
25
28
  end
26
29
 
27
30
  def focusable? = true
@@ -30,8 +33,25 @@ module Tuile
30
33
  # row.
31
34
  attr_reader :footer
32
35
 
33
- # Sets the bottom-row chrome slot. The footer overlays the bottom border at
34
- # full inner width and is positioned automatically; pass `nil` to remove.
36
+ # @return [Sizing] how the footer's width is computed from the window's
37
+ # inner width; defaults to {Sizing::FILL} (the footer spans the full
38
+ # inner width). The footer's height is always 1 (the border row).
39
+ attr_reader :footer_sizing
40
+
41
+ # Sets the footer width policy and re-lays-out the footer.
42
+ # @param sizing [Sizing]
43
+ def footer_sizing=(sizing)
44
+ raise TypeError, "expected Sizing, got #{sizing.inspect}" unless sizing.is_a?(Sizing)
45
+ return if @footer_sizing == sizing
46
+
47
+ @footer_sizing = sizing
48
+ layout_footer
49
+ invalidate # repaint border cells the footer may have just vacated
50
+ end
51
+
52
+ # Sets the bottom-row chrome slot. The footer overlays the bottom border
53
+ # row and is positioned automatically — its width is governed by
54
+ # {#footer_sizing}; pass `nil` to remove.
35
55
  #
36
56
  # Symmetric to {#content=}: validates the new component, swaps parent
37
57
  # pointers, invalidates the old/new components and the window border, and
@@ -110,20 +130,36 @@ module Tuile
110
130
  def caption=(new_caption)
111
131
  @caption = new_caption
112
132
  invalidate
133
+ update_content_size
113
134
  end
114
135
 
115
- # @return [Size] the size needed to fit the window's content, footer
116
- # (width only footer overlays the bottom border), and caption,
117
- # plus the 2-character border. Returns {Size}`.new(2, 2)` when the
118
- # window has no content, footer, or caption.
119
- def content_size
120
- inner_w = [
121
- content&.content_size&.width || 0,
122
- @footer&.content_size&.width || 0,
123
- frame_caption.length
124
- ].max
125
- inner_h = content&.content_size&.height || 0
126
- Size.new(inner_w + 2, inner_h + 2)
136
+ # Sets the new content. Also recomputes the window's natural size.
137
+ # @param new_content [Component, nil]
138
+ def content=(new_content)
139
+ super
140
+ update_content_size
141
+ end
142
+
143
+ # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
144
+ # size changes, and folds a content resize into the window's own
145
+ # natural size (whose change then bubbles to the window's parent — e.g.
146
+ # a {Popup} re-self-sizes). The footer deliberately does *not*
147
+ # participate in the window's {#content_size}: it is decoration
148
+ # overlaying the border, and must not drive the window's size — if it
149
+ # doesn't fit, it is clipped to the inner width.
150
+ # @param child [Component]
151
+ # @return [void]
152
+ def on_child_content_size_changed(child)
153
+ if child.equal?(@footer)
154
+ old_rect = @footer.rect
155
+ layout_footer
156
+ # Repaint on any footer geometry change: a shrinking footer vacates
157
+ # border cells that must be re-dashed (a growing one merely
158
+ # overdraws, but distinguishing isn't worth the code).
159
+ invalidate if @footer.rect != old_rect
160
+ else
161
+ update_content_size
162
+ end
127
163
  end
128
164
 
129
165
  # Fully repaints the window: both frame and contents.
@@ -150,6 +186,7 @@ module Tuile
150
186
  super
151
187
  # The shortcut key is shown in the caption — repaint.
152
188
  invalidate
189
+ update_content_size
153
190
  end
154
191
 
155
192
  protected
@@ -166,7 +203,7 @@ module Tuile
166
203
  return if rect.empty?
167
204
 
168
205
  frame = build_frame(frame_caption)
169
- frame = Rainbow(frame).green if active?
206
+ frame = screen.theme.active_border(frame) if active?
170
207
  screen.print frame
171
208
  end
172
209
 
@@ -206,11 +243,28 @@ module Tuile
206
243
 
207
244
  private
208
245
 
246
+ # Recomputes the window's natural size: content's natural size (or the
247
+ # caption, whichever is wider) plus the 2-character border. The footer
248
+ # is deliberately excluded — see {#on_child_content_size_changed}. A
249
+ # window with no content or caption sizes to `Size.new(2, 2)` (bare
250
+ # border).
251
+ # @return [void]
252
+ def update_content_size
253
+ inner_w = [content&.content_size&.width || 0, frame_caption.length].max
254
+ inner_h = content&.content_size&.height || 0
255
+ self.content_size = Size.new(inner_w + 2, inner_h + 2)
256
+ end
257
+
258
+ # Positions the footer over the bottom border row, with its width
259
+ # resolved by {#footer_sizing} against the inner width. A
260
+ # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
261
+ # rect — i.e. it is invisible, as if never assigned.
209
262
  # @return [void]
210
263
  def layout_footer
211
264
  return if @footer.nil? || rect.empty?
212
265
 
213
- width = [rect.width - 2, 0].max
266
+ available = [rect.width - 2, 0].max
267
+ width = @footer_sizing.resolve(available, @footer.content_size.width)
214
268
  @footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
215
269
  end
216
270
  end
@@ -12,6 +12,8 @@ module Tuile
12
12
  def initialize
13
13
  @rect = Rect.new(0, 0, 0, 0)
14
14
  @active = false
15
+ @content_size = Size::ZERO
16
+ @on_theme_changed = nil
15
17
  end
16
18
 
17
19
  # @return [Rect] the rectangle the component occupies on screen.
@@ -195,6 +197,38 @@ module Tuile
195
197
  # @return [void]
196
198
  def on_focus; end
197
199
 
200
+ # Optional zero-arg listener fired by the base {#on_theme_changed} — the
201
+ # composition-style alternative to overriding the method, for apps that
202
+ # assemble stock components rather than subclass:
203
+ #
204
+ # label.on_theme_changed = -> { label.text = render_status_line }
205
+ #
206
+ # @return [Proc, nil]
207
+ attr_writer :on_theme_changed
208
+
209
+ # Called on every attached component (pre-order, popups included) when
210
+ # {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
211
+ # assignment and on OS appearance flips.
212
+ #
213
+ # Built-in components read {Screen#theme} at paint time, so their accents
214
+ # restyle automatically; this hook exists for *content* whose colors the
215
+ # app baked in from the old theme — a {Label#text} / {List#lines} /
216
+ # {TextView#text} {StyledString} styled with `theme[:accent]` and the
217
+ # like. Only the app knows which of its colors were theme-derived (as
218
+ # opposed to inherent to the data, e.g. log-level colors), so it rebuilds
219
+ # them here, re-running the same code that rendered them initially.
220
+ #
221
+ # Runs on the UI thread; {Screen#theme} already returns the new theme.
222
+ # Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
223
+ # event-loop tick. Do not assign {Screen#theme=} from inside the hook.
224
+ #
225
+ # Subclasses overriding this should call `super` so an assigned
226
+ # {#on_theme_changed=} listener keeps firing.
227
+ # @return [void]
228
+ def on_theme_changed
229
+ @on_theme_changed&.call
230
+ end
231
+
198
232
  # @return [Boolean] true if this component's tree is currently mounted on
199
233
  # the {Screen}, i.e. its root is the {ScreenPane}.
200
234
  def attached? = root == screen.pane
@@ -225,12 +259,27 @@ module Tuile
225
259
 
226
260
  # The {Size} big enough to show the entire component contents without
227
261
  # scrolling. Plain components have no intrinsic content and report
228
- # {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
229
- # {Layout}, {Window}) override this to fold in their content's natural
230
- # extent. Used by callers like {Component::Popup} to auto-size to
231
- # whatever content was assigned, regardless of its concrete type.
262
+ # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
263
+ # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
264
+ # their mutators, so reads are O(1). Used by callers like
265
+ # {Component::Popup} to auto-size to whatever content was assigned,
266
+ # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
232
267
  # @return [Size]
233
- def content_size = Size::ZERO
268
+ attr_reader :content_size
269
+
270
+ # Called by a child component whose {#content_size} just changed (fired
271
+ # from the child's {#content_size=}). Does nothing by default — a plain
272
+ # container is not size-coupled to its children. Containers that derive
273
+ # their own natural size or child layout from a child's natural size
274
+ # override this (e.g. {Component::Window} re-lays-out a
275
+ # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
276
+ # {Component::Popup} re-self-sizes). If the receiver's own
277
+ # {#content_size} changes as a consequence, its {#content_size=} notifies
278
+ # *its* parent in turn — so the event bubbles exactly as far as geometry
279
+ # keeps changing, and stops where it doesn't.
280
+ # @param child [Component] the resized direct child.
281
+ # @return [void]
282
+ def on_child_content_size_changed(child); end
234
283
 
235
284
  # Where the hardware terminal cursor should sit when this component is the
236
285
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
@@ -253,6 +302,26 @@ module Tuile
253
302
  # @return [void]
254
303
  def on_width_changed; end
255
304
 
305
+ # Memoizes the component's natural size and notifies {#parent} via
306
+ # {#on_child_content_size_changed} when the value actually changed.
307
+ # Subclasses call this from their content mutators (`text=`, `add_lines`,
308
+ # `caption=`, …) instead of caching ad-hoc.
309
+ #
310
+ # Call this as the *last* step of a mutator: the parent hook may
311
+ # reentrantly reposition this component (assign {#rect} — e.g. {Window}
312
+ # re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
313
+ # triggers {#on_width_changed} and {#repaint}-related recomputation, so
314
+ # all internal state must already be consistent.
315
+ # @param new_size [Size]
316
+ # @return [void]
317
+ def content_size=(new_size)
318
+ raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
319
+ return if @content_size == new_size
320
+
321
+ @content_size = new_size
322
+ parent&.on_child_content_size_changed(self)
323
+ end
324
+
256
325
  # Invalidates the component: {Screen} records this component as
257
326
  # needs-repaint and once all events are processed, will call {#repaint}.
258
327
  #
@@ -47,17 +47,56 @@ module Tuile
47
47
  latch.wait
48
48
  end
49
49
 
50
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
51
+ # per second, passing a 0-based monotonically increasing tick counter. Use
52
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
53
+ # periodic UI refresh from a background task.
54
+ #
55
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
56
+ # stop it.
57
+ #
58
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
59
+ # exception flows through the normal event-loop error path — i.e.
60
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
61
+ # broken block from spamming `on_error` at the tick rate.
62
+ #
63
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
64
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
65
+ # threads, just more work on the shared scheduler.
66
+ #
67
+ # @param fps [Numeric] firings per second, must be positive. Fractional
68
+ # values are fine (`fps: 0.5` ⇒ one tick every two seconds).
69
+ # @yield [tick] called on the event-loop thread each firing.
70
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
71
+ # @yieldreturn [void]
72
+ # @return [Ticker]
73
+ def tick(fps, &block)
74
+ raise ArgumentError, "block required" unless block
75
+ unless fps.is_a?(Numeric) && fps.positive?
76
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
77
+ end
78
+
79
+ Ticker.new(self, fps, block)
80
+ end
81
+
50
82
  # Runs the event loop and blocks. Must be run from at most one thread at the
51
83
  # same time. Blocks until some thread calls {#stop}. Calls block for all
52
- # events submitted via {#post}; the block is always called from the thread
53
- # running this function.
84
+ # events; the block is always called from the thread running this function.
54
85
  #
55
- # Any exception raised by block is re-thrown, causing this function to
56
- # terminate.
57
- # @yield [event] called for each non-internal event.
86
+ # Any exception raised by the block is re-thrown, causing this function to
87
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
88
+ # without tearing down the loop — see {Screen#event_loop} for an example.
89
+ #
90
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
91
+ # the consumer is responsible for invoking it (typically `event.call`).
92
+ # Yielding rather than dispatching inline means a raise inside the
93
+ # submitted block flows through the consumer's `rescue` like any other
94
+ # event-handler error, instead of bypassing it.
95
+ # @yield [event] called for each posted event.
58
96
  # @yieldparam event [Object] a posted event — typically a {KeyEvent},
59
- # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, or any object pushed
60
- # via {#post}.
97
+ # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
98
+ # or any object pushed via {#post}. {ErrorEvent}s are not yielded — they
99
+ # terminate the loop directly.
61
100
  # @yieldreturn [void]
62
101
  # @return [void]
63
102
  def run_loop(&)
@@ -145,6 +184,31 @@ module Tuile
145
184
  def size = Size.new(width, height)
146
185
  end
147
186
 
187
+ # The terminal's color scheme changed — the user flipped the OS between
188
+ # light and dark appearance. Terminals supporting mode 2031 (kitty,
189
+ # foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
190
+ # (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
191
+ # the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
192
+ # it into this event and {Screen#event_loop} follows by assigning the
193
+ # matching {Theme}.
194
+ #
195
+ # @!attribute [r] scheme
196
+ # @return [Symbol] `:light` or `:dark`.
197
+ class ColorSchemeEvent < Data.define(:scheme)
198
+ # The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
199
+ # light.
200
+ # @return [Regexp]
201
+ REPORT = /\A\e\[\?997;([12])n\z/
202
+
203
+ # @param key [String] key read via {Keys.getkey}.
204
+ # @return [ColorSchemeEvent, nil] nil when `key` is not a
205
+ # color-scheme report.
206
+ def self.parse(key)
207
+ match = REPORT.match(key)
208
+ match && new(match[1] == "2" ? :light : :dark)
209
+ end
210
+ end
211
+
148
212
  # Emitted once when the queue is cleared, all messages are processed and the
149
213
  # event loop will block waiting for more messages. Perfect time for
150
214
  # repainting windows.
@@ -152,6 +216,64 @@ module Tuile
152
216
  include Singleton
153
217
  end
154
218
 
219
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
220
+ # {#cancel}.
221
+ #
222
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
223
+ # submit-block to the owning {EventQueue}; the user's block therefore
224
+ # always runs on the event-loop thread and may freely mutate UI. If the
225
+ # user block raises, the Ticker auto-cancels and the exception is
226
+ # re-raised so it flows through the loop's normal error handling
227
+ # ({Screen#on_error} for the default Tuile setup).
228
+ class Ticker
229
+ # @param event_queue [EventQueue] queue to dispatch tick calls onto.
230
+ # @param fps [Numeric] firings per second (positive).
231
+ # @param block [Proc] called as `block.call(tick_count)` on each fire.
232
+ def initialize(event_queue, fps, block)
233
+ @event_queue = event_queue
234
+ @block = block
235
+ @tick = 0
236
+ # AtomicBoolean rather than a plain ivar: cancel may run on any
237
+ # thread (caller code, the event-loop thread from inside the block,
238
+ # or the IO executor on an error path), and we want both a CAS-style
239
+ # one-shot guard against double-shutdown and well-defined visibility
240
+ # on non-MRI Rubies.
241
+ @cancelled = Concurrent::AtomicBoolean.new(false)
242
+ @timer = Concurrent::TimerTask.new(execution_interval: 1.0 / fps) do
243
+ @event_queue.submit { fire }
244
+ end
245
+ @timer.execute
246
+ end
247
+
248
+ # @return [Boolean] true once {#cancel} has been called.
249
+ def cancelled? = @cancelled.true?
250
+
251
+ # Stops the ticker. Idempotent and safe to call from any thread,
252
+ # including from inside the tick block. Any tick already queued on the
253
+ # event loop at the moment of cancellation is dropped before the user
254
+ # block runs.
255
+ # @return [void]
256
+ def cancel
257
+ return unless @cancelled.make_true # CAS: only the winner shuts down
258
+
259
+ @timer.shutdown
260
+ end
261
+
262
+ private
263
+
264
+ # Runs on the event-loop thread.
265
+ # @return [void]
266
+ def fire
267
+ return if @cancelled.true?
268
+
269
+ @block.call(@tick)
270
+ @tick += 1
271
+ rescue StandardError
272
+ cancel
273
+ raise
274
+ end
275
+ end
276
+
155
277
  private
156
278
 
157
279
  # @return [void]
@@ -169,8 +291,6 @@ module Tuile
169
291
  # while the loop's own backtrace shows up in the wrapper.
170
292
  raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
171
293
  end
172
- elsif event.is_a? Proc
173
- event.call
174
294
  else
175
295
  yield event
176
296
  end
@@ -183,8 +303,7 @@ module Tuile
183
303
  @key_thread = Thread.new do
184
304
  loop do
185
305
  key = Keys.getkey
186
- event = MouseEvent.parse(key)
187
- event = KeyEvent.new(key) if event.nil?
306
+ event = MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
188
307
  post event
189
308
  end
190
309
  rescue StandardError => e
@@ -4,6 +4,10 @@ module Tuile
4
4
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
5
5
  # away and submitted events are thrown away. Intended for testing only.
6
6
  class FakeEventQueue
7
+ def initialize
8
+ @tickers = []
9
+ end
10
+
7
11
  # @return [Boolean]
8
12
  def locked? = true
9
13
  # @return [void]
@@ -27,5 +31,70 @@ module Tuile
27
31
  # @param event [Object]
28
32
  # @return [void]
29
33
  def post(event); end
34
+
35
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
36
+ # only fires when a test calls {#tick_once}. The `fps` argument is
37
+ # validated the same way the real queue validates it, then discarded —
38
+ # the fake has no clock, so frame cadence is up to the test.
39
+ #
40
+ # @param fps [Numeric] firings per second, must be positive. Validated
41
+ # for parity with {EventQueue#tick}; otherwise unused.
42
+ # @yield [tick] called on each {#tick_once}.
43
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
44
+ # @yieldreturn [void]
45
+ # @return [FakeTicker]
46
+ def tick(fps, &block)
47
+ raise ArgumentError, "block required" unless block
48
+ unless fps.is_a?(Numeric) && fps.positive?
49
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
50
+ end
51
+
52
+ FakeTicker.new(block).tap { |t| @tickers << t }
53
+ end
54
+
55
+ # Test helper: fires every live ticker's user block once and prunes
56
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
57
+ # per call regardless of any ticker's fps — the fake has no clock, so
58
+ # tests pump N frames by calling this N times.
59
+ # @return [void]
60
+ def tick_once
61
+ @tickers.reject!(&:cancelled?)
62
+ @tickers.each(&:fire)
63
+ end
64
+
65
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
66
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
67
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
68
+ class FakeTicker
69
+ # @param block [Proc] called as `block.call(tick_count)` on each {#fire}.
70
+ def initialize(block)
71
+ @block = block
72
+ @tick = 0
73
+ @cancelled = false
74
+ end
75
+
76
+ # @return [Boolean] true once {#cancel} has been called.
77
+ def cancelled? = @cancelled
78
+
79
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
80
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
81
+ # pass.
82
+ # @return [void]
83
+ def cancel
84
+ @cancelled = true
85
+ end
86
+
87
+ # Invokes the user block with the current tick counter, then advances.
88
+ # No-op when {#cancelled?}. Typically driven by
89
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
90
+ # wants to drive a single ticker.
91
+ # @return [void]
92
+ def fire
93
+ return if @cancelled
94
+
95
+ @block.call(@tick)
96
+ @tick += 1
97
+ end
98
+ end
30
99
  end
31
100
  end
@@ -54,5 +54,13 @@ module Tuile
54
54
  def invalidated_clear
55
55
  @invalidated.clear
56
56
  end
57
+
58
+ private
59
+
60
+ # No terminal probing in tests: skip {TerminalBackground.detect}
61
+ # (which would write an OSC 11 query to the test runner's TTY and
62
+ # steal its input) and pin the deterministic default.
63
+ # @return [Symbol]
64
+ def detect_scheme = :dark
57
65
  end
58
66
  end
data/lib/tuile/keys.rb CHANGED
@@ -160,6 +160,16 @@ module Tuile
160
160
  char += $stdin.read(6 - char.bytesize)
161
161
  end
162
162
 
163
+ # Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
164
+ # can outgrow the 5-byte gulp above — the mode-2031 color-scheme
165
+ # notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
166
+ # bytes after the `\e`. Drain to the final byte with blocking 1-byte
167
+ # reads so the tail doesn't surface as phantom keypresses. Keyboard
168
+ # sequences never start with `\e[?`, so this can't eat a regular key.
169
+ if char.start_with?("\e[?")
170
+ char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
171
+ end
172
+
163
173
  char
164
174
  end
165
175
  end