przn 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.
data/lib/przn/renderer.rb CHANGED
@@ -15,12 +15,6 @@ module Przn
15
15
 
16
16
  DEFAULT_SCALE = 2
17
17
 
18
- # Default `relative_height` (as a percent of terminal height) applied to
19
- # image blocks that don't carry an explicit one. Caps how much of the
20
- # screen a single image can occupy; the rest leaves predictable margin
21
- # for the slide footer and avoids placement-clearing edge cases in some
22
- # terminals when an image lands right against the bottom row.
23
- DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT = 70
24
18
 
25
19
  # `mode:` controls whether `{::note}` / `<note>` segments are rendered:
26
20
  # :solo — dim-inline (today's behavior), default for stand-alone runs.
@@ -28,49 +22,169 @@ module Przn
28
22
  # :presenter — dim-inline (so the presenter sees them in context) and
29
23
  # ALSO aggregated separately for the side panel via
30
24
  # Slide#notes; this renderer just keeps the inline copy.
31
- def initialize(terminal, base_dir: '.', theme: nil, mode: :solo)
25
+ # `export_mode:` true when the renderer is driving a PDF export (currently
26
+ # ScreenshotPdfExporter): suppresses the 🐇/🐢 runner bar in favor of the
27
+ # plain `N / M` counter, since the emoji animation belongs on a live screen,
28
+ # not a static page.
29
+ # Swap in a fresh theme without rebuilding the renderer (so the
30
+ # image / kitty-upload caches survive). Used by the `r` reload
31
+ # path so a theme.yml edit takes effect mid-session.
32
+ attr_accessor :theme, :presentation
33
+ attr_reader :export_mode
34
+
35
+ def initialize(terminal, base_dir: '.', theme: nil, mode: :solo, export_mode: false, presentation: nil)
32
36
  @terminal = terminal
33
37
  @base_dir = base_dir
34
38
  @theme = theme || Theme.default
35
39
  @mode = mode
40
+ @export_mode = export_mode
41
+ # Deck-wide context for `<ref id="..."/>` resolution. nil is
42
+ # fine for unit tests that exercise a single slide; the
43
+ # render_ref path no-ops without a presentation wired.
44
+ @presentation = presentation
36
45
  @image_cache = {}
37
46
  @kitty_uploads = {}
38
47
  @mutex = Mutex.new
48
+ # Horizontal cell offset applied by `term_move` to flow-mode block
49
+ # rendering. Set per slot inside `render_layout`; zero everywhere
50
+ # else. Screen-absolute emits (footer, runner bar, render_at,
51
+ # `<img x y>` absolute mode) bypass it and call @terminal.move_to
52
+ # directly.
53
+ @x_offset = 0
54
+ # Per-slot text-style overrides (`size` / `family` / `color`)
55
+ # populated by `render_layout` for the duration of one slot.
56
+ # render_heading h1, render_paragraph, and the :body defaults in
57
+ # render_segments_scaled consult this so a `cover.title` slot
58
+ # with `size: xxx-large` actually makes the title bigger.
59
+ @slot_style = nil
60
+ # Kitty image id of the previous slide's background image (when
61
+ # set via `<bg image="..."/>` or `background.image:` in theme).
62
+ # Tracked so the placement can be deleted before the next slide
63
+ # applies its own bg — keeps the upload cached but wipes the
64
+ # visible artifact.
65
+ @bg_image_id = nil
39
66
  end
40
67
 
41
- def render(slide, current:, total:, started_at: nil)
68
+ def render(slide, current:, total:, started_at: nil, step: 0, progress: 1.0, opacity: 1.0)
42
69
  @mutex.synchronize do
70
+ # Slide-level fade: when the controller drives a fractional
71
+ # opacity, bracket the entire slide render in OSC 7772
72
+ # `cell-alpha` so every text write inherits the alpha as
73
+ # terminal-side paint state. This is the slide-transition
74
+ # building block; the per-block `with_block_opacity` path
75
+ # (action-driven fades) is independent and still nests inside.
76
+ slide_alpha = opacity.to_f.clamp(0.0, 1.0)
77
+ slide_fade = slide_alpha < 1.0
78
+ @terminal.write "\e]7772;cell-alpha;#{slide_alpha}\a" if slide_fade
79
+ # Per-block visibility for incremental reveals. Each `<wait/>`
80
+ # block bumps the step counter, so block_step[b] = "step at
81
+ # which block b becomes visible." A block is rendered iff
82
+ # block_step[b] <= step; otherwise its flow space is *reserved*
83
+ # but no bytes reach the terminal. See `suppressed_render`.
84
+ @block_step = compute_block_step(slide.blocks)
85
+ @current_step = step
86
+ # Action overrides accumulated up to the current step. Keyed
87
+ # by target id; merged into a block's attrs at render time.
88
+ # When `progress < 1.0`, actions at the JUST-REVEALED step
89
+ # (block_step == current_step) blend partway between their
90
+ # prior state and final values — that's what animates the
91
+ # smooth move. See `compute_effective_state`.
92
+ @effective_state = compute_effective_state(slide.blocks, @block_step, step, progress)
43
93
  @terminal.clear
94
+ # Wipe the previous slide's `<img>` / shape placements (cached
95
+ # image data stays alive). The current slide re-emits its own
96
+ # placements below; the alternative — letting placements leak
97
+ # — would mean every prior slide's images bleed through onto
98
+ # later ones, since kitty placements aren't tied to the cell
99
+ # buffer that @terminal.clear empties.
100
+ @terminal.write ImageUtil.kitty_delete_all_placements if ImageUtil.kitty_terminal?
44
101
  apply_slide_background(slide)
45
102
  w = @terminal.width
46
103
  h = @terminal.height
47
104
 
48
- row = if current == 0
49
- content_height = calculate_height(slide.blocks, w)
50
- usable_height = h - 1
51
- [(usable_height - content_height) / 2 + 1, 1].max
52
- else
53
- 2
54
- end
55
-
56
- pending_align = nil
105
+ # Pre-pass: register shape placements AND fully-positioned `<img>`
106
+ # placements (both x and y set) BEFORE any text writes. Echoes'
107
+ # kitty graphics implementation erases the cells covered by a
108
+ # placement when the placement is registered, then text writes
109
+ # to those cells later restore the cell buffer (via
110
+ # erase_multicell_at). The placement itself stays in the GUI's
111
+ # @placements list and re-blits on top — so where the SVG / PNG
112
+ # is transparent, the underlying text shows through. Solid
113
+ # fills and opaque PNGs still occlude (Echoes has no z-index
114
+ # yet); thin strokes and outlined shapes coexist with text
115
+ # cleanly. Without this pass, text rendered BEFORE a positioned
116
+ # image was clobbered by the placement's cell-buffer wipe and
117
+ # disappeared even where the image had no opaque pixels.
118
+ #
119
+ # Hidden positioned content (block_step > current_step) is
120
+ # skipped outright — absolute placements don't take flow space
121
+ # so there's nothing to reserve, and we don't want a hidden
122
+ # image's bytes on the terminal before its reveal step.
57
123
  slide.blocks.each do |block|
58
- if block[:type] == :align
59
- pending_align = block[:align]
60
- else
61
- row = render_block(block, w, row, align: pending_align)
62
- pending_align = nil
124
+ next if hidden?(block)
125
+ case block[:type]
126
+ when :shape
127
+ render_shape(block)
128
+ when :image
129
+ attrs = effective_attrs(block)
130
+ # Only "both-axes" positioned images can be pre-placed —
131
+ # x-only / y-only images still need the flow `row` to
132
+ # decide the unpinned axis, so they stay in the
133
+ # layout/flow pass below.
134
+ next unless attrs['x'] && attrs['y']
135
+ render_image(block, w, 1)
63
136
  end
64
137
  end
65
138
 
66
- if @theme.rabbit
67
- draw_runner_bar(h, w, current, total, started_at)
139
+ # Resolve the slide's effective layout name:
140
+ # `# Title {layout=name}` on the slide wins.
141
+ # Otherwise: slide 0 prefers `cover` (if the theme ships one),
142
+ # every other slide falls through to `default`.
143
+ # `{layout=none}` is an explicit per-slide opt-out — useful
144
+ # when a custom default layout isn't right for one slide.
145
+ layout_name = slide.layout
146
+ layout_name ||= 'cover' if current == 0 && @theme.layouts['cover']
147
+ layout_name ||= 'default'
148
+ layout_name = nil if layout_name == 'none'
149
+ layout = layout_name && @theme.layouts[layout_name]
150
+
151
+ if layout
152
+ render_layout(slide, layout, w, h)
68
153
  else
69
- status = " #{current + 1} / #{total} "
70
- @terminal.move_to(h, w - status.size)
71
- @terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
154
+ # `{layout=none}` (or a theme stripped of `default`) — render
155
+ # blocks top-down from row 2 with no slot routing.
156
+ row = 2
157
+ pending_align = nil
158
+ slide.blocks.each do |block|
159
+ case block[:type]
160
+ when :align then pending_align = block[:align]
161
+ when :wait then next
162
+ when :action then next # mutation already applied via @effective_state
163
+ else
164
+ row = render_block_or_reserve(block, w, row, align: pending_align)
165
+ pending_align = nil
166
+ end
167
+ end
168
+ end
169
+
170
+ # `# Title {counter=hide}` opts a slide (typically the title
171
+ # page) out of the bottom row. Remembered for the runner-bar
172
+ # refresh thread, which only knows the slide index.
173
+ @counter_hidden = counter_hidden?(slide)
174
+ unless @counter_hidden
175
+ if counter_duration && !@export_mode
176
+ draw_runner_bar(h, w, current, total, started_at)
177
+ else
178
+ status = " #{current + 1} / #{total} "
179
+ @terminal.move_to(h, w - status.size)
180
+ @terminal.write "#{counter_color_open}#{status}#{ANSI[:reset]}"
181
+ end
72
182
  end
73
183
 
184
+ # Close the slide-level cell-alpha bracket so the next render
185
+ # (or any other terminal write outside the renderer) lands at
186
+ # full opacity. Mirrors the per-block reset in `with_block_opacity`.
187
+ @terminal.write "\e]7772;cell-alpha;1\a" if slide_fade
74
188
  @terminal.flush
75
189
  end
76
190
  end
@@ -81,6 +195,19 @@ module Przn
81
195
  # against `render` via the renderer's mutex so terminal writes don't
82
196
  # interleave.
83
197
  def preload(slide)
198
+ # Code-block tokenizer warmup. Pays the `require 'rouge'` and
199
+ # per-lexer autoload costs *here*, on the background preload
200
+ # thread, so the first user-visible render of a slide with
201
+ # fenced code doesn't sit waiting for Rouge to load. Pure CPU
202
+ # work into a thread-safe in-process cache — no terminal I/O —
203
+ # so it deliberately runs outside the renderer mutex (no point
204
+ # blocking a foreground render on tokenization).
205
+ slide.blocks.each do |block|
206
+ next unless block[:type] == :code_block && block[:language]
207
+ CodeHighlighter.highlight(block[:content], block[:language],
208
+ theme: highlight_theme_name)
209
+ end
210
+
84
211
  return unless ImageUtil.kitty_terminal?
85
212
 
86
213
  @mutex.synchronize do
@@ -94,22 +221,804 @@ module Przn
94
221
  end
95
222
  end
96
223
 
224
+ # Public entrypoint for the controller's runner-bar refresh thread.
225
+ # Recomputes just the bottom-row 🐇/🐢 / N-M counter — leaves the
226
+ # rest of the slide untouched. No-op when the theme didn't opt
227
+ # into the runner bar (no `counter.duration`) or we're in export
228
+ # mode. Serialized against full slide renders via the renderer's
229
+ # mutex so the background thread can't tear writes.
230
+ def redraw_runner_bar(current:, total:, started_at:)
231
+ return if @export_mode
232
+ return unless counter_duration
233
+ return if @counter_hidden
234
+ @mutex.synchronize do
235
+ draw_runner_bar(@terminal.height, @terminal.width, current, total, started_at)
236
+ @terminal.flush
237
+ end
238
+ end
239
+
240
+ # Mouse hit test for dragging the 🐇. Returns the grab offset (cell
241
+ # column of the press minus the rabbit's left column) when `row` /
242
+ # `col` land on the rabbit, nil otherwise — including whenever the
243
+ # runner bar isn't on screen. A cell of slack either side makes the
244
+ # 4-cell glyph easier to catch.
245
+ def runner_grab(row, col, current:, total:)
246
+ return nil unless runner_bar_shown?
247
+ h = @terminal.height
248
+ track = runner_track(@terminal.width, current, total)
249
+ return nil unless track && row >= [h - 1, 1].max
250
+ rabbit_col = runner_col(current, [total - 1, 1].max, *track)
251
+ return nil unless col.between?(rabbit_col - 1, rabbit_col + EMOJI_RUNNER_CELLS)
252
+ col - rabbit_col
253
+ end
254
+
255
+ # Inverse of `runner_col`: the slide index whose rabbit position is
256
+ # nearest to `rabbit_col`, clamped to the deck. `current` fixes the
257
+ # track geometry (the left anchor's digit count shifts it).
258
+ def runner_slide_at(rabbit_col, current:, total:)
259
+ track = runner_track(@terminal.width, current, total)
260
+ return current unless track
261
+ track_left, track_right = track
262
+ span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
263
+ return current if span <= 0
264
+ max = [total - 1, 1].max
265
+ ((rabbit_col - track_left).to_f / span * max).round.clamp(0, total - 1)
266
+ end
267
+
268
+ # How many discrete steps a slide has — one per `<wait/>` block,
269
+ # plus one for the initial state with nothing revealed. The
270
+ # controller uses this to decide whether Space advances within
271
+ # the slide or flips to the next one.
272
+ def step_count(slide)
273
+ slide.blocks.sum { |b| block_step_count(b) } + 1
274
+ end
275
+
276
+ # Number of step boundaries a block contributes — `:wait` blocks
277
+ # contribute 1, list blocks contribute one per item flagged with
278
+ # `:wait` (an inline `<wait/>` on a list item, parsed by the
279
+ # `LEADING_WAIT_RE` strip path), fragment blocks contribute 1
280
+ # (each `<at fragment="…">` adds an implicit step boundary before
281
+ # itself, matching reveal.js parity), everything else contributes 0.
282
+ def block_step_count(block)
283
+ case block[:type]
284
+ when :wait then 1
285
+ when :unordered_list, :ordered_list
286
+ block[:items].count { |i| i[:wait] }
287
+ else
288
+ block[:fragment] ? 1 : 0
289
+ end
290
+ end
291
+
292
+ # Longest `duration_ms` among the `:action` and fragment blocks
293
+ # whose `block_step` matches the given step. Returns 0 if neither
294
+ # an action nor a fragment at that step has a duration — the
295
+ # controller takes the snap path when this is 0, the animation loop
296
+ # otherwise. Fragment blocks pull their duration from
297
+ # `theme.fragment.duration` (default 300 ms).
298
+ def max_duration_for_step(slide, step)
299
+ bstep = compute_block_step(slide.blocks)
300
+ max = 0.0
301
+ slide.blocks.each do |b|
302
+ next unless (bstep[b] || 0) == step
303
+ d = if b[:type] == :action && b[:duration_ms]
304
+ b[:duration_ms]
305
+ elsif b[:fragment]
306
+ fragment_duration_ms
307
+ end
308
+ max = d if d && d > max
309
+ end
310
+ max
311
+ end
312
+
313
+ # Duration (ms) for a fragment animation — read from
314
+ # `theme.fragment.duration` with a hardcoded 300 ms fallback. Lives
315
+ # here (not on the controller) so any caller of the renderer reuses
316
+ # the same value. Returned as Float so `Controller#animate_step`'s
317
+ # `elapsed / duration_ms` lerps fractionally per frame — an integer
318
+ # value here triggers integer division and pins progress at 0 until
319
+ # the final frame.
320
+ def fragment_duration_ms
321
+ ((@theme && @theme.respond_to?(:fragment) && @theme.fragment && @theme.fragment[:duration]) || 300).to_f
322
+ end
323
+
97
324
  private
98
325
 
326
+ # Tiny stand-in for @terminal that swallows writes. Used by
327
+ # `suppressed_render` so a hidden block's render path still runs
328
+ # end-to-end (returning the same row advancement it would have
329
+ # produced visibly), but no bytes reach the real terminal.
330
+ # Mirrors the @terminal interface the renderer touches (see
331
+ # `grep -o '@terminal\.\w*' lib/przn/renderer.rb`).
332
+ class NullTerm
333
+ def initialize(real)
334
+ @width = real.width
335
+ @height = real.height
336
+ @cell_px = real.respond_to?(:cell_pixel_size) ? real.cell_pixel_size : [10, 20]
337
+ end
338
+ def width; @width; end
339
+ def height; @height; end
340
+ def cell_pixel_size; @cell_px; end
341
+ def write(_); end
342
+ def move_to(_, _); end
343
+ def clear; end
344
+ def flush; end
345
+ end
346
+
347
+ # Walk slide.blocks once and assign each block the step at which
348
+ # it becomes visible (= the count of `:wait` blocks preceding it).
349
+ # Returns a Hash keyed by the block dict itself (object identity)
350
+ # because the same dicts flow through route_blocks_to_slots into
351
+ # the layout pass and through the no-layout pass — both pick them
352
+ # back up by reference.
353
+ def compute_block_step(blocks)
354
+ step = 0
355
+ map = {}
356
+ blocks.each do |block|
357
+ # Fragment blocks declare their own step boundary AHEAD of their
358
+ # rendering — the bump runs BEFORE the assignment so the fragment
359
+ # block lands AT its post-boundary step, not on the step preceding
360
+ # it. `:wait` and list-item waits keep the existing post-assignment
361
+ # bump (the wait/item itself stays "at" the step it's revealed at,
362
+ # following blocks live one step further).
363
+ step += 1 if block[:fragment]
364
+ map[block] = step
365
+ case block[:type]
366
+ when :wait then step += 1
367
+ when :unordered_list, :ordered_list
368
+ step += block[:items].count { |i| i[:wait] }
369
+ end
370
+ end
371
+ map
372
+ end
373
+
374
+ # Build the "effective state" map for the current step: walk every
375
+ # `:action` block in slide order, and for each one that's already
376
+ # been triggered (block_step[a] <= current_step), accumulate its
377
+ # attr overrides under `state[target_id]`. Later actions targeting
378
+ # the same id overwrite earlier ones, so the final state is "the
379
+ # latest action that fired against each target."
380
+ #
381
+ # When `progress < 1.0`, an action at `block_step == current_step`
382
+ # is treated as partway through its `duration_ms` interpolation:
383
+ # each numeric attr blends from its prior value (whatever earlier
384
+ # actions left, or the target block's original attr) toward the
385
+ # action's new value. Actions on EARLIER steps are always fully
386
+ # applied — progress only affects the just-revealed ones.
387
+ #
388
+ # Returns `{target_id => {key => value, ...}}` where keys are
389
+ # stringified attr names — `effective_attrs` reconciles against
390
+ # the target block's own key style at merge time.
391
+ def compute_effective_state(blocks, block_step, current_step, progress = 1.0)
392
+ # Pre-index id'd blocks so an animating action can look up its
393
+ # target's *original* attr values when no earlier action has
394
+ # claimed the same key yet. Walks into `:group` children too so
395
+ # `<action target="inner"/>` on the same slide can drive a child
396
+ # of a sibling group, mirroring the deck-wide find_by_id index.
397
+ by_id = {}
398
+ index_ids = lambda do |bs|
399
+ bs.each do |b|
400
+ a = b[:attrs] || {}
401
+ bid = a['id'] || a[:id]
402
+ by_id[bid] = b if bid
403
+ index_ids.call(b[:children]) if b[:children].is_a?(Array)
404
+ end
405
+ end
406
+ index_ids.call(blocks)
407
+
408
+ state = {}
409
+ # Fragment pass — synthesize an action-equivalent state for each
410
+ # block carrying `:fragment`. Walks first so a later explicit
411
+ # `<action target="…">` overrides anything the fragment leaves.
412
+ blocks.each do |block|
413
+ next unless block[:fragment]
414
+ apply_fragment_state(state, block, block_step, current_step, progress)
415
+ end
416
+
417
+ blocks.each do |block|
418
+ next unless block[:type] == :action
419
+ bstep = block_step[block] || 0
420
+ next if bstep > current_step
421
+
422
+ attrs = block[:attrs] || {}
423
+ target = attrs[:target] || attrs['target']
424
+ next unless target
425
+
426
+ # An action animates only when (a) it carries duration_ms,
427
+ # (b) its step is the just-revealed one, and (c) we were
428
+ # called with progress < 1.0. Otherwise it snaps (v2 behavior).
429
+ animating = block[:duration_ms] && bstep == current_step && progress < 1.0
430
+
431
+ bucket = (state[target] ||= {})
432
+ target_block = by_id[target]
433
+ target_attrs = (target_block && target_block[:attrs]) || {}
434
+
435
+ attrs.each do |k, v|
436
+ key = k.to_s
437
+ next if key == 'target' || key == 'duration'
438
+ if animating
439
+ prev = bucket[key] || target_attrs[key] || target_attrs[key.to_sym]
440
+ # `opacity` has a semantic default of 1.0 — a block with no
441
+ # explicit opacity is fully opaque. Other attrs (x / y / w /
442
+ # h) have no universal default, so lerp_attr's nil→snap
443
+ # behavior stays correct for them. Without this, a plain
444
+ # fade-out (`<action opacity="0" .../>` targeting a block
445
+ # that never declared its initial opacity) would snap to 0
446
+ # instead of interpolating from 1.
447
+ prev = '1.0' if prev.nil? && key == 'opacity'
448
+ bucket[key] = lerp_attr(prev, v, progress)
449
+ else
450
+ bucket[key] = v
451
+ end
452
+ end
453
+ end
454
+ state
455
+ end
456
+
457
+ # Cells of positional offset baked into the four directional
458
+ # fragment styles. `fade-up` starts FRAG_OFFSET_Y cells *below* its
459
+ # declared y (so it slides upward into place); fade-down mirrors.
460
+ # Horizontal styles use the wider FRAG_OFFSET_X (text is roughly
461
+ # half as tall as it is wide on a cell grid). All hardcoded in v1;
462
+ # a theme knob can land later if anyone wants to tune the throw.
463
+ FRAG_OFFSET_Y = 2
464
+ FRAG_OFFSET_X = 4
465
+
466
+ # Synthesize an action-equivalent override for a fragment block at
467
+ # the current step. Writes into `state[block.id]` the same way the
468
+ # explicit `<action>` pass does — but uses the fragment's `style` to
469
+ # decide what the "before" state is (the offset position + opacity 0)
470
+ # versus the "after" state (the block's own declared attrs). When
471
+ # `current_step < bstep`, the block hasn't been revealed yet — pin it
472
+ # at the offset position. When `current_step == bstep` and
473
+ # `progress < 1.0`, lerp from offset → declared. Once
474
+ # `current_step > bstep` we leave no override; the block's own attrs
475
+ # take over (which is exactly the declared, final state).
476
+ def apply_fragment_state(state, block, block_step, current_step, progress)
477
+ bid = (block[:attrs] && (block[:attrs]['id'] || block[:attrs][:id])) or return
478
+ bstep = block_step[block] || 0
479
+ return if current_step > bstep # past reveal: declared attrs win
480
+ initial = fragment_initial_attrs(block) or return
481
+ bucket = (state[bid] ||= {})
482
+ if current_step < bstep
483
+ # Pre-reveal: pin the offset state. No lerp.
484
+ initial.each { |k, v| bucket[k] = v }
485
+ else
486
+ # Animating at bstep — lerp each offset attr toward the declared
487
+ # value (or 1.0 for opacity, which has no per-attr fallback).
488
+ declared = block[:attrs] || {}
489
+ initial.each do |k, v|
490
+ target = declared[k] || declared[k.to_sym] || (k == 'opacity' ? '1.0' : nil)
491
+ bucket[k] = lerp_attr(v, target, progress)
492
+ end
493
+ end
494
+ end
495
+
496
+ # Compute the "before reveal" attrs for a fragment style — the
497
+ # state the renderer pins the block at when current_step has not
498
+ # yet reached the fragment's own step. Returns `{key => value}`
499
+ # with string keys (matching `state[id]` convention). Non-numeric
500
+ # x/y (e.g. `x="center"`) skip the offset; only opacity gets set —
501
+ # the block still slides into existence visually via the fade.
502
+ def fragment_initial_attrs(block)
503
+ attrs = block[:attrs] || {}
504
+ case block[:fragment].to_s
505
+ when 'fade-up'
506
+ {'opacity' => '0'}.merge(offset_attr('y', attrs, +FRAG_OFFSET_Y))
507
+ when 'fade-down'
508
+ {'opacity' => '0'}.merge(offset_attr('y', attrs, -FRAG_OFFSET_Y))
509
+ when 'fade-left'
510
+ {'opacity' => '0'}.merge(offset_attr('x', attrs, +FRAG_OFFSET_X))
511
+ when 'fade-right'
512
+ {'opacity' => '0'}.merge(offset_attr('x', attrs, -FRAG_OFFSET_X))
513
+ when 'fade-in'
514
+ {'opacity' => '0'}
515
+ else
516
+ nil # unknown style — no-op
517
+ end
518
+ end
519
+
520
+ # Compute the offset variant of one positional attr. Numeric coords
521
+ # shift by `delta` cells; non-numeric coords (keyword `center`, `%`
522
+ # values, etc.) pass through unchanged so the lerp still has a
523
+ # consistent prev/target and falls back to "snap" on its own.
524
+ def offset_attr(key, attrs, delta)
525
+ v = attrs[key] || attrs[key.to_sym]
526
+ return {} if v.nil?
527
+ parsed = parse_dimension(v)
528
+ return {key => v} unless parsed && parsed[1] == '' # cells only
529
+ shifted = parsed[0] + delta
530
+ shifted = shifted.to_i if shifted == shifted.to_i
531
+ {key => shifted.to_s}
532
+ end
533
+
534
+ # Split a coordinate-ish string into `[numeric_float, unit_string]`.
535
+ # `"15"` → `[15.0, ""]`, `"50%"` → `[50.0, "%"]`, `"100px"` →
536
+ # `[100.0, "px"]`, `"4c"` → `[4.0, "c"]`. Returns nil for garbage —
537
+ # `lerp_attr` uses that as the "can't interpolate, snap" signal.
538
+ def parse_dimension(str)
539
+ return nil if str.nil?
540
+ m = str.to_s.strip.match(/\A(-?\d+(?:\.\d+)?)([a-zA-Z%]*)\z/)
541
+ m && [m[1].to_f, m[2]]
542
+ end
543
+
544
+ # Blend two attr strings at `progress` in [0, 1].
545
+ #
546
+ # lerp_attr("15", "50", 0.5) → "32.5"
547
+ # lerp_attr("20%", "80%", 0.5) → "50.0%"
548
+ #
549
+ # Returns `target_str` unchanged when:
550
+ # - prev is nil (no prior value to blend from),
551
+ # - either side fails to parse as a dimension,
552
+ # - the two sides use different units (can't meaningfully blend),
553
+ # - progress is at or past 1.0 (settled).
554
+ # These are graceful instant-fallback paths — the animation just
555
+ # snaps for that attr instead of trying to fake an interpolation.
556
+ def lerp_attr(prev_str, target_str, progress)
557
+ return target_str if prev_str.nil? || progress >= 1.0
558
+ prev = parse_dimension(prev_str)
559
+ targ = parse_dimension(target_str)
560
+ return target_str unless prev && targ && prev[1] == targ[1]
561
+ blended = prev[0] + progress * (targ[0] - prev[0])
562
+ num = blended == blended.to_i ? blended.to_i.to_s : blended.to_s
563
+ "#{num}#{prev[1]}"
564
+ end
565
+
566
+ # Merge a block's stored attrs with any action-driven overrides
567
+ # for that block's id. The block's original key style (string vs
568
+ # symbol) is preserved so the downstream render path keeps
569
+ # reading attrs the same way it always has — only the values
570
+ # change.
571
+ def effective_attrs(block)
572
+ attrs = block[:attrs] || {}
573
+ return attrs unless @effective_state
574
+ id = attrs['id'] || attrs[:id]
575
+ override = id && @effective_state[id]
576
+ return attrs unless override
577
+
578
+ result = attrs.dup
579
+ override.each do |k, v|
580
+ if attrs.key?(k.to_sym)
581
+ result[k.to_sym] = v
582
+ else
583
+ result[k.to_s] = v
584
+ end
585
+ end
586
+ result
587
+ end
588
+
589
+ # True when this block hasn't been revealed yet at the current
590
+ # step. Pre-pass uses this to skip hidden positioned content
591
+ # outright; the flow pass uses it to switch to `suppressed_render`.
592
+ def hidden?(block)
593
+ ((@block_step && @block_step[block]) || 0) > (@current_step || 0)
594
+ end
595
+
596
+ # Visibility-aware wrapper around `render_block`. Visible blocks
597
+ # render normally; hidden ones go through `suppressed_render`,
598
+ # which advances the row the same amount but writes nothing —
599
+ # so layout stays stable as the user advances steps, instead of
600
+ # everything reflowing up each time a new block reveals.
601
+ def render_block_or_reserve(block, width, row, align: nil)
602
+ if hidden?(block)
603
+ suppressed_render(block, width, row, align: align)
604
+ else
605
+ render_block(block, width, row, align: align)
606
+ end
607
+ end
608
+
609
+ # Run the full render_block path with @terminal swapped to a
610
+ # NullTerm. The block's natural row advancement is preserved
611
+ # (every height-aware render function computes its own row math
612
+ # and returns the new row), so reserving layout space happens
613
+ # automatically without duplicating per-block-type measurements.
614
+ def suppressed_render(block, width, row, align: nil)
615
+ real = @terminal
616
+ @terminal = NullTerm.new(real)
617
+ begin
618
+ render_block(block, width, row, align: align)
619
+ ensure
620
+ @terminal = real
621
+ end
622
+ end
623
+
624
+ # Dry-render the slot's block list and return how many rows it
625
+ # would occupy — the content height that `y: center` / `bottom`
626
+ # need before they can position the slot. Blocks that don't
627
+ # render (`:wait`, `:action`) are dropped; the `@measuring_last`
628
+ # flag is set for the FINAL renderable block so its trailing
629
+ # padding is excluded from the measurement (without that h1's
630
+ # built-in "+4 rows after the heading" makes a single-h1 slot
631
+ # measure 4 rows taller than it actually occupies, biasing the
632
+ # centred position upward by 2 rows).
633
+ def measure_blocks_content_height(blocks, width, slot_align)
634
+ visible = blocks.reject { |b| %i[wait action align].include?(b[:type]) }
635
+ return 0 if visible.empty?
636
+
637
+ real = @terminal
638
+ @terminal = NullTerm.new(real)
639
+ row = 0
640
+ pending_align = nil
641
+ begin
642
+ blocks.each do |block|
643
+ case block[:type]
644
+ when :align then pending_align = block[:align]
645
+ when :wait, :action then next
646
+ else
647
+ @measuring_last = block.equal?(visible.last)
648
+ row = render_block_or_reserve(block, width, row, align: pending_align || slot_align)
649
+ pending_align = nil
650
+ end
651
+ end
652
+ ensure
653
+ @measuring_last = false
654
+ @terminal = real
655
+ end
656
+ row
657
+ end
658
+
659
+ # Move the terminal cursor in flow-mode block coordinates: `col` is
660
+ # measured from the left of the current slot (or from the screen edge
661
+ # when no slot is active, @x_offset = 0). Screen-absolute emits
662
+ # (footer, runner bar, render_at, `<img x y>`) bypass this and call
663
+ # @terminal.move_to directly so a layout context can't shift them.
664
+ def term_move(row, col)
665
+ @terminal.move_to(row, col + @x_offset)
666
+ end
667
+
668
+ # Body-text OSC 66 scale (1-7). Reads `theme.font.size` and resolves
669
+ # via Parser::SIZE_SCALES (so `xx-small` / `2` / `large` all map to
670
+ # the same table title.size uses), falling back to DEFAULT_SCALE
671
+ # when unset. Consulted everywhere body text is rendered or
672
+ # vertically advanced — paragraphs, list items, blockquotes,
673
+ # code blocks, definition lists, table rows, blank rows, h2–h6.
674
+ def body_scale
675
+ size = @theme.font && @theme.font[:size]
676
+ (size && Parser::SIZE_SCALES[size.to_s]) || DEFAULT_SCALE
677
+ end
678
+
679
+ # Parsed `theme.counter.duration` in seconds, or nil when unset /
680
+ # unparseable. Truthy here is what opts the bottom row into the
681
+ # 🐇 / 🐢 runner bar; nil falls back to the plain " N / M " counter.
682
+ def counter_duration
683
+ raw = @theme.counter && @theme.counter[:duration]
684
+ raw && Theme.parse_duration(raw)
685
+ end
686
+
687
+ # The 🐇 / 🐢 bar is what's on the bottom row right now.
688
+ def runner_bar_shown?
689
+ !@export_mode && counter_duration && !@counter_hidden
690
+ end
691
+
692
+ # True when the slide's h1 IAL carries `counter=hide`. Duck-typed on
693
+ # `attrs` so callers can hand in lightweight slide stand-ins.
694
+ def counter_hidden?(slide)
695
+ attrs = slide.respond_to?(:attrs) && slide.attrs
696
+ !!(attrs && attrs[:counter].to_s == 'hide')
697
+ end
698
+
699
+ # Open-SGR for the bottom-row counter (plain `N / M` and the
700
+ # runner-bar anchor numbers). Honors `theme.counter.color`
701
+ # (named ANSI or 6-digit hex); falls back to the dim ANSI default.
702
+ def counter_color_open
703
+ c = @theme.counter && @theme.counter[:color]
704
+ (c && !c.to_s.empty?) ? color_code(c.to_s) : ANSI[:dim]
705
+ end
706
+
707
+ # Resolve the current slot's `size:` override into an OSC 66 scale
708
+ # (1-7). nil when no slot styling applies or the slot didn't set
709
+ # a size — caller falls back to its own default (HEADING_SCALES,
710
+ # body_scale, etc.).
711
+ def slot_scale
712
+ return nil unless @slot_style
713
+ name = @slot_style[:size]
714
+ name && Parser::SIZE_SCALES[name]
715
+ end
716
+
717
+ # The active slot's `family:` override, or nil.
718
+ def slot_family
719
+ @slot_style && @slot_style[:family]
720
+ end
721
+
722
+ # The active slot's `color:` override, or nil.
723
+ def slot_color
724
+ @slot_style && @slot_style[:color]
725
+ end
726
+
727
+ # Group a slide's blocks into per-slot lists and render each slot in
728
+ # its own region. The first slot named `title` (if any) is auto-filled
729
+ # from the h1; remaining slots fill in declaration order, advanced by
730
+ # a `<slot/>` block. `<slot name="...">` jumps to that slot if it
731
+ # exists. Slots whose blocks would resolve to an invalid region (x/y
732
+ # missing or unparseable) are skipped silently.
733
+ def render_layout(slide, slots, width, height)
734
+ buckets = route_blocks_to_slots(slide.blocks, slots)
735
+
736
+ cell_w, cell_h = @terminal.cell_pixel_size
737
+ slots.each do |slot|
738
+ blocks = buckets[slot.name] || []
739
+ next if blocks.empty?
740
+ # Resolve slot width + height first — keyword `x:` / `y:`
741
+ # need them to know where the slot's edges should land.
742
+ w = resolve_at_coord(slot.width, width, cell_px: cell_w)
743
+ h = resolve_at_coord(slot.height, height, cell_px: cell_h)
744
+ next unless w
745
+ # `x:` may be numeric / `%` / `c` / `px` (slot starts at that
746
+ # column, alignment defaults to :left) OR a keyword (`left` /
747
+ # `center` / `right`). For keywords, the slot's effective
748
+ # starting column is computed from `width` and the slide
749
+ # width, AND the alignment passed into block rendering is
750
+ # taken from the keyword too — replacing the separate
751
+ # `align:` key that used to do this.
752
+ x_kw = alignment_keyword(slot.x, :x)
753
+ if x_kw
754
+ x = alignment_keyword_cell(x_kw, width, w, :x)
755
+ slot_align = x_kw.to_sym # :left | :center | :middle | :right
756
+ slot_align = :center if slot_align == :middle
757
+ else
758
+ x = resolve_at_coord(slot.x, width, cell_px: cell_w)
759
+ slot_align = nil
760
+ end
761
+ next unless x
762
+
763
+ # Install the slot's text-style overrides BEFORE measuring,
764
+ # so the dry-render that y-keyword centering relies on picks
765
+ # up the slot's `size:` (which an h1 at `xxxx-large` needs to
766
+ # know about to wrap at scale 7 instead of the default 4 and
767
+ # to claim 7 rows per line). Restored in `ensure` below.
768
+ prev_offset = @x_offset
769
+ prev_style = @slot_style
770
+ @x_offset = x - 1
771
+ @slot_style = {size: slot.size, family: slot.family, color: slot.color}.compact
772
+ @slot_style = nil if @slot_style.empty?
773
+ begin
774
+ # `y:` mirrors `x:` — same five forms — but doesn't carry an
775
+ # "alignment of blocks WITHIN the slot" side-effect (the slot
776
+ # just starts rendering downward from row `y`). Keyword
777
+ # behavior centers the *content*, not the slot's region:
778
+ # `y: center` puts the rendered content's vertical centre at
779
+ # the slide's vertical centre regardless of `slot.height`,
780
+ # `y: bottom` anchors the content's last row to the slide's
781
+ # last row. `slot.height` only matters for numeric `y:` and
782
+ # for the (already-respected) slot-content extent.
783
+ y_kw = alignment_keyword(slot.y, :y)
784
+ y =
785
+ case y_kw
786
+ when 'top'
787
+ 1
788
+ when 'center', 'middle'
789
+ content_h = measure_blocks_content_height(blocks, w, slot_align)
790
+ [(height - content_h) / 2 + 1, 1].max
791
+ when 'bottom'
792
+ content_h = measure_blocks_content_height(blocks, w, slot_align)
793
+ [height - content_h + 1, 1].max
794
+ else
795
+ resolve_at_coord(slot.y, height, cell_px: cell_h)
796
+ end
797
+ next unless y
798
+ row = y
799
+ pending_align = nil
800
+ blocks.each do |block|
801
+ case block[:type]
802
+ when :align then pending_align = block[:align]
803
+ when :wait then next
804
+ when :action then next # mutation already applied via @effective_state
805
+ else
806
+ row = render_block_or_reserve(block, w, row, align: pending_align || slot_align)
807
+ pending_align = nil
808
+ end
809
+ end
810
+ ensure
811
+ @x_offset = prev_offset
812
+ @slot_style = prev_style
813
+ end
814
+ end
815
+ end
816
+
817
+ # Walk the slide's blocks once and bucket each one into a slot name.
818
+ # The h1 (first heading at level 1) goes into the `title` slot when
819
+ # one exists. Remaining blocks fill the first non-title slot until a
820
+ # `:slot` block advances the cursor (`<slot/>` → next slot;
821
+ # `<slot name="X"/>` → jump to slot X by name). Blocks past the last
822
+ # slot are silently dropped — the slot list is the layout contract.
823
+ def route_blocks_to_slots(blocks, slots)
824
+ buckets = Hash.new { |h, k| h[k] = [] }
825
+ title_slot = slots.find { |s| s.name == 'title' }
826
+ non_title_slots = title_slot ? slots.reject { |s| s.equal?(title_slot) } : slots
827
+ cursor = 0 # index into non_title_slots
828
+
829
+ blocks.each do |block|
830
+ if title_slot && block[:type] == :heading && block[:level] == 1 && !buckets.key?('title')
831
+ buckets['title'] << block
832
+ elsif block[:type] == :slot
833
+ if block[:name]
834
+ idx = non_title_slots.index { |s| s.name == block[:name] }
835
+ cursor = idx if idx
836
+ else
837
+ cursor += 1
838
+ end
839
+ else
840
+ next if cursor >= non_title_slots.size
841
+ buckets[non_title_slots[cursor].name] << block
842
+ end
843
+ end
844
+ buckets
845
+ end
846
+
99
847
  def render_block(block, width, row, align: nil)
100
- case block[:type]
101
- when :heading then render_heading(block, width, row)
102
- when :paragraph then render_paragraph(block, width, row, align: align)
103
- when :code_block then render_code_block(block, width, row)
104
- when :unordered_list then render_unordered_list(block, width, row)
105
- when :ordered_list then render_ordered_list(block, width, row)
106
- when :definition_list then render_definition_list(block, width, row)
107
- when :blockquote then render_blockquote(block, width, row)
108
- when :table then render_table(block, width, row)
109
- when :image then render_image(block, width, row)
110
- when :blank then row + DEFAULT_SCALE
111
- when :bg then row
112
- else row + 1
848
+ with_block_opacity(block) do
849
+ case block[:type]
850
+ when :heading then render_heading(block, width, row, align: align)
851
+ when :paragraph then render_paragraph(block, width, row, align: align)
852
+ when :code_block then render_code_block(block, width, row)
853
+ when :unordered_list then render_unordered_list(block, width, row)
854
+ when :ordered_list then render_ordered_list(block, width, row)
855
+ when :definition_list then render_definition_list(block, width, row)
856
+ when :blockquote then render_blockquote(block, width, row)
857
+ when :table then render_table(block, width, row)
858
+ when :image then render_image_or_skip(block, width, row)
859
+ when :mermaid then render_mermaid(block, width, row)
860
+ when :shape then row # rendered in render()'s pre-pass
861
+ when :blank then row + body_scale
862
+ when :bg then row
863
+ when :slot then row
864
+ when :wait then row # step boundary marker, not a renderable
865
+ when :action then row # state mutation, applied via effective_attrs
866
+ when :at then render_at(block); row
867
+ when :ref then render_ref(block, width, row, align: align)
868
+ when :group then render_group(block, width, row, align: align)
869
+ else row + 1
870
+ end
871
+ end
872
+ end
873
+
874
+ # Walk a `<group>`'s children in declaration order, threading the
875
+ # row through each child's render. Mirrors the top-level slide walk
876
+ # (renderer.rb's `slide.blocks.each` body and the layout path's
877
+ # `blocks.each`) — the same `:align` / `:wait` / `:action` handling
878
+ # so a group is a transparent container: anything that works at
879
+ # slide top level works inside a group. v1 caveat: `:wait` children
880
+ # don't drive sub-steps (compute_block_step only walks slide.blocks,
881
+ # not group children), so all children render at every step.
882
+ #
883
+ # `:shape` and fully-positioned `:image` children get dispatched to
884
+ # their renderers DIRECTLY here, not through render_block_or_reserve.
885
+ # At slide top level those types are drawn in render()'s pre-pass
886
+ # and skipped during the flow walk; the pre-pass doesn't recurse
887
+ # into group children, so without these inline calls a `<rect>` or
888
+ # positioned `<img>` inside a `<group>` would silently disappear
889
+ # (and so would the ref'd copy on another slide).
890
+ def render_group(block, width, row, align: nil)
891
+ pending_align = align
892
+ (block[:children] || []).each do |child|
893
+ case child[:type]
894
+ when :align then pending_align = child[:align]
895
+ when :wait, :action then next
896
+ when :shape then render_shape(child)
897
+ when :image
898
+ attrs = effective_attrs(child)
899
+ if attrs['x'] && attrs['y']
900
+ render_image(child, width, row)
901
+ else
902
+ row = render_block_or_reserve(child, width, row, align: pending_align)
903
+ pending_align = nil
904
+ end
905
+ else
906
+ row = render_block_or_reserve(child, width, row, align: pending_align)
907
+ pending_align = nil
908
+ end
909
+ end
910
+ row
911
+ end
912
+
913
+ # Render a `<mermaid>` block by shelling out to mmdc once per
914
+ # unique source (cached for the session), then dispatch as an
915
+ # inline image. Per-block IAL (`{height=70%}` etc.) carries
916
+ # through to the synthetic image's attrs, so sizing is the same
917
+ # surface `<img>` already offers.
918
+ #
919
+ # When mmdc fails (not installed, headless Chrome missing,
920
+ # bad-syntax source, …) we fall back to rendering the mermaid
921
+ # source as a plain code block so the slide still shows
922
+ # something useful instead of silently going blank.
923
+ def render_mermaid(block, width, row)
924
+ png_path = MermaidRenderer.render(block[:content])
925
+ if png_path && File.exist?(png_path)
926
+ synthetic = {
927
+ type: :image,
928
+ path: png_path,
929
+ alt: '',
930
+ title: '',
931
+ attrs: (block[:attrs] || {}).transform_keys(&:to_s)
932
+ }
933
+ render_image_or_skip(synthetic, width, row)
934
+ else
935
+ # Plain-text fallback. `language: nil` skips syntax highlight;
936
+ # the dim-gray bg + body font shape still makes the source
937
+ # readable and obvious that something failed to render.
938
+ render_code_block(
939
+ {type: :code_block, content: block[:content], language: nil},
940
+ width, row
941
+ )
942
+ end
943
+ end
944
+
945
+ # Resolve a `<ref id="x"/>` block to its source declaration anywhere
946
+ # in the deck and re-dispatch through render_block. Same-tag attrs
947
+ # on the `<ref>` (typically x / y) override the source's. The
948
+ # synthetic clone is stripped of `id` so an `<action target="x"/>`
949
+ # on the *ref* slide can't accidentally bind to it — actions stay
950
+ # per-slide; the clone has no separate identity in v1.
951
+ def render_ref(block, width, row, align: nil)
952
+ return row unless @presentation
953
+ id = block[:attrs] && (block[:attrs][:id] || block[:attrs]['id'])
954
+ return row unless id
955
+ source = @presentation.find_by_id(id)
956
+ return row unless source
957
+
958
+ # Deep-clone the source so a group source's children don't share
959
+ # storage with the deck (otherwise the id-scrub would mutate the
960
+ # canonical block). Every descendant's id is stripped too — a
961
+ # per-slide <action target="..."/> on the ref slide must not bind
962
+ # to any nested child of the synthesised clone.
963
+ synthetic = deep_clone_strip_ids(source)
964
+ src_attrs = source[:attrs] || {}
965
+ # Merge the ref's own attrs over the clone's top-level attrs,
966
+ # preserving the source's per-key style (string vs symbol) so
967
+ # downstream renderers keep reading attrs the way they always
968
+ # have — same trick effective_attrs uses at the action site.
969
+ merged = synthetic[:attrs]
970
+ (block[:attrs] || {}).each do |k, v|
971
+ next if k.to_s == 'id' # never carry id forward
972
+ if src_attrs.key?(k.to_sym)
973
+ merged[k.to_sym] = v
974
+ elsif src_attrs.key?(k.to_s)
975
+ merged[k.to_s] = v
976
+ elsif k.is_a?(Symbol)
977
+ merged[k] = v
978
+ else
979
+ merged[k.to_s] = v
980
+ end
981
+ end
982
+
983
+ render_block(synthetic, width, row, align: align)
984
+ end
985
+
986
+ # Recursive clone of a block tree with every node's `id` stripped.
987
+ # Top-level dup is shallow but `:attrs` and `:children` get their
988
+ # own dup'd containers, so callers can mutate the result without
989
+ # touching the source. Used by render_ref to produce a tree whose
990
+ # descendants can't be action-targeted on the ref slide.
991
+ def deep_clone_strip_ids(block)
992
+ cloned = block.dup
993
+ attrs = (block[:attrs] || {}).dup
994
+ attrs.delete(:id)
995
+ attrs.delete('id')
996
+ cloned[:attrs] = attrs
997
+ if block[:children].is_a?(Array)
998
+ cloned[:children] = block[:children].map { |c| deep_clone_strip_ids(c) }
999
+ end
1000
+ cloned
1001
+ end
1002
+
1003
+ # Wrap a block's render with the Echoes-private OSC 7772 cell-alpha
1004
+ # paint state when an action has driven its `opacity` (or the block
1005
+ # itself ships an explicit `opacity=`). Non-Echoes terminals ignore
1006
+ # the OSC. v1 only fades `put_char`-driven content (paragraphs,
1007
+ # lists, code blocks, tables, blockquotes) — h1 multicells, runner
1008
+ # emojis, and images stay opaque.
1009
+ def with_block_opacity(block)
1010
+ attrs = effective_attrs(block)
1011
+ alpha_str = attrs['opacity'] || attrs[:opacity]
1012
+ return yield unless alpha_str
1013
+
1014
+ alpha = alpha_str.to_f.clamp(0.0, 1.0)
1015
+ return yield if alpha >= 1.0 # fully opaque — no fade to apply
1016
+
1017
+ @terminal.write "\e]7772;cell-alpha;#{alpha}\a"
1018
+ begin
1019
+ yield
1020
+ ensure
1021
+ @terminal.write "\e]7772;cell-alpha;1\a"
113
1022
  end
114
1023
  end
115
1024
 
@@ -122,8 +1031,17 @@ module Przn
122
1031
  attrs = block ? block[:attrs] : (@theme.background || {})
123
1032
 
124
1033
  @terminal.write "\e]7772;bg-clear\a"
1034
+ clear_background_image
125
1035
  return if attrs.empty?
126
1036
 
1037
+ # An `image:` background wins over color / gradient — the image
1038
+ # covers the whole slide area at z: -1 so subsequent text and
1039
+ # `<img>` placements draw on top.
1040
+ if (image = attrs[:image])
1041
+ apply_background_image(image.to_s)
1042
+ return
1043
+ end
1044
+
127
1045
  if (color = attrs[:color])
128
1046
  @terminal.write "\e]7772;bg-color;#{color}\a"
129
1047
  return
@@ -137,39 +1055,816 @@ module Przn
137
1055
  @terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
138
1056
  end
139
1057
 
1058
+ # Cover the slide with a Kitty Graphics image at z: -1 so text
1059
+ # drawn afterward layers on top. PNG only for now (relies on the
1060
+ # in-process upload path; JPGs would need a kitten icat detour).
1061
+ # Silently no-ops on non-Kitty terminals — the bg-clear emitted
1062
+ # above already wiped any prior Echoes color / gradient.
1063
+ def apply_background_image(path)
1064
+ return unless ImageUtil.kitty_terminal?
1065
+ resolved = resolve_image_path(path)
1066
+ return unless File.exist?(resolved) && ImageUtil.png?(resolved)
1067
+
1068
+ image_id = ensure_kitty_uploaded(resolved)
1069
+ @bg_image_id = image_id
1070
+ @terminal.move_to(1, 1)
1071
+ @terminal.write ImageUtil.kitty_place(
1072
+ image_id: image_id,
1073
+ cols: @terminal.width,
1074
+ rows: @terminal.height,
1075
+ z: -1
1076
+ )
1077
+ end
1078
+
1079
+ # Delete the previous slide's bg-image placement (if any) so it
1080
+ # doesn't bleed through to a slide that defines no image bg.
1081
+ # Image data stays cached so revisiting the same bg doesn't
1082
+ # re-upload.
1083
+ def clear_background_image
1084
+ return unless @bg_image_id && ImageUtil.kitty_terminal?
1085
+ @terminal.write ImageUtil.kitty_delete_placements(image_id: @bg_image_id)
1086
+ @bg_image_id = nil
1087
+ end
1088
+
1089
+ # Place text at an absolute (column, row) on the slide, escaping the
1090
+ # normal top-down paragraph flow. Coordinates are 1-based terminal cells
1091
+ # to match the CSI cursor-position escape. A trailing `%` interprets the
1092
+ # value as a percentage of the terminal's width (for `x=`) or height
1093
+ # (for `y=`) — `x="50%" y="50%"` lands at the middle of the pane,
1094
+ # auto-resizing with the terminal. Content is parsed inline so
1095
+ # `<size>`, `<color>`, `<font>`, **bold**, etc. all work inside `<at>`.
1096
+ # The block contributes 0 to the slide's layout height so it doesn't
1097
+ # push subsequent content down.
1098
+ def render_at(block)
1099
+ attrs = effective_attrs(block)
1100
+ cell_w, cell_h = @terminal.cell_pixel_size
1101
+
1102
+ segments = Parser.parse_inline(block[:content].to_s)
1103
+ # Split on `:break` (from <br>) and emit each chunk on its own
1104
+ # row at the same x. `render_segments_scaled` itself doesn't
1105
+ # know about :break — it's a styling renderer, not a wrapping
1106
+ # one — so we handle the line break here.
1107
+ lines = [[]]
1108
+ segments.each do |seg|
1109
+ seg[0] == :break ? (lines << []) : (lines.last << seg)
1110
+ end
1111
+ line_heights = lines.map { |l| max_segment_scale(l, body_scale) }
1112
+ # Content extent: widest line wins horizontally, total of per-line
1113
+ # heights vertically. Needed up front so `x="center"` / `"right"`
1114
+ # and `y="center"` / `"bottom"` can resolve against the actual
1115
+ # rendered footprint, not just the terminal dimensions.
1116
+ content_w = lines.map { |l| segments_visible_cells(l, body_scale) }.max || 0
1117
+ content_h = line_heights.sum
1118
+
1119
+ x = resolve_at_axis(attrs[:x], @terminal.width, cell_w, :x, content_w)
1120
+ y = resolve_at_axis(attrs[:y], @terminal.height, cell_h, :y, content_h)
1121
+ return if x.nil? || y.nil?
1122
+
1123
+ # Advance y by each line's *own* height (the max inline scale
1124
+ # of its segments). When the whole `<at>` is wrapped in
1125
+ # `<font size=1>` or `<size=1>`, the lines pack tight at
1126
+ # 1 cell apart; a `<size=5>` line takes 5 cells before the
1127
+ # next line starts. Falls back to body_scale when a line
1128
+ # carries no explicit size, matching the pre-fix behavior.
1129
+ y_cursor = y
1130
+ lines.zip(line_heights).each do |line_segs, line_h|
1131
+ @terminal.move_to(y_cursor, x)
1132
+ @terminal.write render_segments_scaled(line_segs, body_scale, line_height: line_h)
1133
+ y_cursor += line_h
1134
+ end
1135
+ end
1136
+
1137
+ # `<at x|y="…">` extension over `resolve_at_coord` that adds the
1138
+ # keyword forms `left|center|right` (x-axis) and `top|center|bottom`
1139
+ # (y-axis). `middle` is accepted as a synonym of `center` for
1140
+ # readers who think of vertical centering that way. Keywords need
1141
+ # the content's rendered footprint (`content_extent`) so the
1142
+ # caller measures the parsed-inline lines first, then asks here.
1143
+ # Anything that isn't a recognised keyword falls through to
1144
+ # `resolve_at_coord`'s numeric / `%` / `px` / `c` parsing.
1145
+ def resolve_at_axis(raw, max, cell_px, axis, content_extent)
1146
+ cell = alignment_keyword_cell(raw, max, content_extent, axis)
1147
+ return cell if cell
1148
+ resolve_at_coord(raw, max, cell_px: cell_px)
1149
+ end
1150
+
1151
+ # Shared keyword → 1-based cell math used by both `<at>` and
1152
+ # `<img>`'s `x` / `y` resolvers. The right / bottom branches
1153
+ # anchor the content's far edge at `max`, so a 4-cell-wide
1154
+ # block at `x="right"` on an 80-wide pane occupies cols 77-80
1155
+ # — flush to the edge, no margin. Use percent (`x="95%"`) when
1156
+ # you want a margin.
1157
+ def alignment_keyword_cell(raw, max, content_extent, axis)
1158
+ s = raw.to_s.strip.downcase
1159
+ case axis
1160
+ when :x
1161
+ case s
1162
+ when 'left' then 1
1163
+ when 'center', 'middle' then [(max - content_extent) / 2 + 1, 1].max
1164
+ when 'right' then [max - content_extent + 1, 1].max
1165
+ end
1166
+ when :y
1167
+ case s
1168
+ when 'top' then 1
1169
+ when 'center', 'middle' then [(max - content_extent) / 2 + 1, 1].max
1170
+ when 'bottom' then [max - content_extent + 1, 1].max
1171
+ end
1172
+ end
1173
+ end
1174
+
1175
+ # Lower-case keyword string when `raw` is one of the alignment
1176
+ # keywords recognised on `axis`; nil otherwise. Used by
1177
+ # `render_image` to detect keywords up front and defer the cell
1178
+ # resolution until target_cols / target_rows are known.
1179
+ def alignment_keyword(raw, axis)
1180
+ s = raw.to_s.strip.downcase
1181
+ keywords = axis == :x ? %w[left center middle right] : %w[top center middle bottom]
1182
+ keywords.include?(s) ? s : nil
1183
+ end
1184
+
1185
+ # Compute a single line's vertical extent in terminal cells. Each
1186
+ # segment contributes either its explicit `<size=...>` / `<font
1187
+ # size="...">` scale, or `default_scale` when it has no size of
1188
+ # its own; the line's height is the max across all of them.
1189
+ #
1190
+ # Crucially this can return *less* than `default_scale` — a line
1191
+ # entirely wrapped in `<font size=1>` returns 1, so `<br>`-stacked
1192
+ # small text packs tight at 1 cell per line instead of body_scale
1193
+ # cells. Plain text mixed with a small-sized tag still uses
1194
+ # body_scale (the plain part needs the room).
1195
+ def max_segment_scale(segments, default_scale)
1196
+ return default_scale if segments.empty?
1197
+ segments.map { |seg|
1198
+ type = seg[0]
1199
+ size_key =
1200
+ case type
1201
+ when :tag then seg[2]
1202
+ when :font then (seg[2] || {})[:size]
1203
+ end
1204
+ Parser::SIZE_SCALES[size_key] || default_scale
1205
+ }.max
1206
+ end
1207
+
1208
+ # Draw a Keynote-style shape primitive (rect, circle, ellipse, line,
1209
+ # polyline, polygon) by composing a tiny self-contained SVG document
1210
+ # and shipping it via the Kitty Graphics Protocol's direct-data mode.
1211
+ # Echoes content-sniffs the payload and rasterizes it through its
1212
+ # native CoreGraphics fast path (sub-millisecond for path-only SVGs,
1213
+ # which these always are).
1214
+ #
1215
+ # Geometry is authored in slide-cell coords (or `N%` of the terminal
1216
+ # w/h) but composed into the SVG in **pixel** coords. The SVG
1217
+ # viewBox matches the cell-quantized rasterization target exactly,
1218
+ # which keeps 1 user unit = 1 pixel and avoids the anisotropic
1219
+ # stretch that would otherwise turn `<circle>` into an ellipse
1220
+ # (terminal cells are ~1:2 wide-to-tall). For "axis-agnostic"
1221
+ # extents (`r`, `stroke-width`) we use `cell_w` as the canonical
1222
+ # unit so a `circle r="5"` renders as a true circle.
1223
+ # Shapes are absolute-positioned and contribute 0 to the layout flow.
1224
+ def render_shape(block)
1225
+ return unless ImageUtil.kitty_terminal?
1226
+ kind = block[:kind]
1227
+ attrs = effective_attrs(block)
1228
+ cell_w, cell_h = @terminal.cell_pixel_size
1229
+ return unless cell_w&.positive? && cell_h&.positive?
1230
+
1231
+ geom = resolve_shape_geometry(kind, attrs, cell_w, cell_h)
1232
+ return unless geom
1233
+
1234
+ sw_user = if attrs['stroke-width']
1235
+ attrs['stroke-width'].to_f
1236
+ elsif open_shape?(kind)
1237
+ SHAPE_DEFAULT_STROKE_WIDTH
1238
+ else
1239
+ 0.0
1240
+ end
1241
+ sw_px = sw_user * cell_w
1242
+ pad = sw_px / 2.0
1243
+
1244
+ # Arrows need an arrowhead computed from the resolved stroke width
1245
+ # (the head dimensions scale with stroke). expand_arrow_geometry
1246
+ # adds the triangle vertices to `geom` and grows the bbox to fit.
1247
+ geom = expand_arrow_geometry(geom, sw_px) if kind == :arrow
1248
+
1249
+ gx_min = geom[:bbox_x] - pad
1250
+ gy_min = geom[:bbox_y] - pad
1251
+ gx_max = geom[:bbox_x] + geom[:bbox_w] + pad
1252
+ gy_max = geom[:bbox_y] + geom[:bbox_h] + pad
1253
+
1254
+ # Quantize the pixel bbox to whole slide cells.
1255
+ place_col0 = (gx_min / cell_w).floor # 0-indexed cell col
1256
+ place_row0 = (gy_min / cell_h).floor
1257
+ end_col0 = (gx_max / cell_w).ceil
1258
+ end_row0 = (gy_max / cell_h).ceil
1259
+ cols = end_col0 - place_col0
1260
+ rows = end_row0 - place_row0
1261
+ return if cols < 1 || rows < 1
1262
+
1263
+ # viewBox in pixels covers the cell-quantized footprint exactly.
1264
+ vb_x = place_col0 * cell_w
1265
+ vb_y = place_row0 * cell_h
1266
+ vb_w = cols * cell_w
1267
+ vb_h = rows * cell_h
1268
+
1269
+ svg = build_shape_svg(kind, attrs, geom, sw_px, vb_x, vb_y, vb_w, vb_h)
1270
+ image_id = ensure_kitty_inline_uploaded(svg)
1271
+ @terminal.move_to(place_row0 + 1, place_col0 + 1)
1272
+ # z=-1 draws the shape below text but above the slide background,
1273
+ # so heading text / paragraphs at the same cells remain legible.
1274
+ # An explicit `z="..."` attr lets authors put a shape on top.
1275
+ z = attrs['z'] ? attrs['z'].to_i : -1
1276
+ @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: cols, rows: rows, z: z)
1277
+ end
1278
+
1279
+ # SVG paint / presentation attributes passed through to the shape
1280
+ # element verbatim. Anything else on `block[:attrs]` is treated as
1281
+ # geometry (consumed per-shape) and not re-emitted.
1282
+ SHAPE_PAINT_ATTRS = %w[
1283
+ fill stroke stroke-width opacity fill-opacity stroke-opacity
1284
+ stroke-linecap stroke-linejoin stroke-dasharray stroke-miterlimit
1285
+ fill-rule transform
1286
+ ].freeze
1287
+
1288
+ SHAPE_DEFAULT_STROKE_WIDTH = 0.2
1289
+
1290
+ OPEN_SHAPES = %i[line polyline arrow path].freeze
1291
+
1292
+ def open_shape?(kind)
1293
+ OPEN_SHAPES.include?(kind)
1294
+ end
1295
+
1296
+ # Resolve a shape's geometry attrs to pixel coordinates. Positional
1297
+ # attrs (x, y, cx, cy, x1, y1, x2, y2, polyline/polygon points) are
1298
+ # 1-indexed cell coords matching `<at>` semantics — cell N maps to
1299
+ # pixel `(N-1) * cell_dim`. Size attrs (width, height, rx, ry) are
1300
+ # cell counts → `N * cell_dim` pixels. `r` and other "axis-agnostic"
1301
+ # extents use `cell_w` as the canonical unit so a `circle r="5"`
1302
+ # renders visually circular regardless of cell aspect ratio.
1303
+ # Returns a hash with the per-shape geometry in pixels plus a
1304
+ # `:bbox_*` quadruple covering the shape's pixel extent, or nil if
1305
+ # any required attr is missing / unparseable.
1306
+ def resolve_shape_geometry(kind, attrs, cell_w, cell_h)
1307
+ case kind
1308
+ when :rect
1309
+ x = to_px(attrs['x'], :position, :x, cell_w, cell_h)
1310
+ y = to_px(attrs['y'], :position, :y, cell_w, cell_h)
1311
+ w_ = to_px(attrs['width'], :size, :x, cell_w, cell_h)
1312
+ h_ = to_px(attrs['height'], :size, :y, cell_w, cell_h)
1313
+ return nil unless x && y && w_ && h_
1314
+ {bbox_x: x, bbox_y: y, bbox_w: w_, bbox_h: h_, x: x, y: y, w: w_, h: h_,
1315
+ rx: to_px(attrs['rx'], :size, :x, cell_w, cell_h),
1316
+ ry: to_px(attrs['ry'], :size, :y, cell_w, cell_h)}
1317
+ when :circle
1318
+ cx = to_px(attrs['cx'], :position, :x, cell_w, cell_h)
1319
+ cy = to_px(attrs['cy'], :position, :y, cell_w, cell_h)
1320
+ # r is axis-agnostic: use cell_w on both axes so the rasterized
1321
+ # shape is a true circle, not a vertical ellipse.
1322
+ r = to_px(attrs['r'], :size, :x, cell_w, cell_h)
1323
+ return nil unless cx && cy && r
1324
+ {bbox_x: cx - r, bbox_y: cy - r, bbox_w: 2 * r, bbox_h: 2 * r, cx: cx, cy: cy, r: r}
1325
+ when :ellipse
1326
+ cx = to_px(attrs['cx'], :position, :x, cell_w, cell_h)
1327
+ cy = to_px(attrs['cy'], :position, :y, cell_w, cell_h)
1328
+ rx = to_px(attrs['rx'], :size, :x, cell_w, cell_h)
1329
+ ry = to_px(attrs['ry'], :size, :y, cell_w, cell_h)
1330
+ return nil unless cx && cy && rx && ry
1331
+ {bbox_x: cx - rx, bbox_y: cy - ry, bbox_w: 2 * rx, bbox_h: 2 * ry, cx: cx, cy: cy, rx: rx, ry: ry}
1332
+ when :line, :arrow
1333
+ # `<arrow>` shares the line's geometry surface; the renderer
1334
+ # adds a filled triangular head at (x2, y2) once stroke_width
1335
+ # is resolved (see expand_arrow_geometry).
1336
+ x1 = to_px(attrs['x1'], :position, :x, cell_w, cell_h)
1337
+ y1 = to_px(attrs['y1'], :position, :y, cell_w, cell_h)
1338
+ x2 = to_px(attrs['x2'], :position, :x, cell_w, cell_h)
1339
+ y2 = to_px(attrs['y2'], :position, :y, cell_w, cell_h)
1340
+ return nil unless x1 && y1 && x2 && y2
1341
+ {bbox_x: [x1, x2].min, bbox_y: [y1, y2].min,
1342
+ bbox_w: (x2 - x1).abs, bbox_h: (y2 - y1).abs,
1343
+ x1: x1, y1: y1, x2: x2, y2: y2}
1344
+ when :polyline, :polygon
1345
+ pts = parse_shape_points(attrs['points'], cell_w, cell_h)
1346
+ return nil if pts.nil? || pts.empty?
1347
+ xs = pts.map(&:first)
1348
+ ys = pts.map(&:last)
1349
+ {bbox_x: xs.min, bbox_y: ys.min,
1350
+ bbox_w: xs.max - xs.min, bbox_h: ys.max - ys.min,
1351
+ points: pts}
1352
+ when :path
1353
+ # Parse `d`, compute bbox in pixels, and rewrite the path data
1354
+ # from cell-space (what the user wrote) into pixel-space (what
1355
+ # the SVG viewBox expects). Rewriting avoids wrapping in a
1356
+ # `<g transform="scale(...)">` — a non-uniform scale would
1357
+ # render strokes elliptically, which is not what anyone wants.
1358
+ path_to_pixels(attrs['d'], cell_w, cell_h)
1359
+ end
1360
+ end
1361
+
1362
+ # Convert a single coord attribute to pixels.
1363
+ # role: :position — 1-indexed cell N → pixel (N-1)*cell_dim
1364
+ # :size — cell count N → pixel N*cell_dim
1365
+ # axis: :x → cell_w & terminal.width; :y → cell_h & terminal.height
1366
+ # `%` is allowed and resolves against the corresponding terminal
1367
+ # extent before the cell→pixel conversion.
1368
+ def to_px(raw, role, axis, cell_w, cell_h)
1369
+ return nil if raw.nil?
1370
+ s = raw.to_s.strip
1371
+ return nil if s.empty?
1372
+
1373
+ cell_dim = (axis == :x) ? cell_w : cell_h
1374
+ total_cells = (axis == :x) ? @terminal.width : @terminal.height
1375
+
1376
+ cells =
1377
+ if s.end_with?('%')
1378
+ s.chomp('%').to_f / 100.0 * total_cells
1379
+ elsif s =~ /\A-?[\d.]+\z/
1380
+ s.to_f
1381
+ end
1382
+ return nil if cells.nil?
1383
+
1384
+ role == :position ? (cells - 1) * cell_dim : cells * cell_dim
1385
+ end
1386
+
1387
+ # Parse a `points="x1,y1 x2,y2 …"` attribute into resolved [x, y]
1388
+ # pixel-coord pairs. Coords are 1-indexed cells or `N%`; each
1389
+ # x uses `:x` axis, each y uses `:y` axis. Returns nil if the token
1390
+ # count is odd or any individual coord fails to parse.
1391
+ def parse_shape_points(raw, cell_w, cell_h)
1392
+ return nil if raw.nil?
1393
+ tokens = raw.to_s.split(/[\s,]+/).reject(&:empty?)
1394
+ return nil if tokens.empty? || tokens.size.odd?
1395
+ pts = []
1396
+ tokens.each_slice(2) do |xs, ys|
1397
+ x = to_px(xs, :position, :x, cell_w, cell_h)
1398
+ y = to_px(ys, :position, :y, cell_w, cell_h)
1399
+ return nil if x.nil? || y.nil?
1400
+ pts << [x, y]
1401
+ end
1402
+ pts
1403
+ end
1404
+
1405
+ # Tokenizer / rewriter for SVG `<path d="...">` data. Returns
1406
+ # `{bbox_x:, bbox_y:, bbox_w:, bbox_h:, d:}` (all in pixel space)
1407
+ # or nil if the path data has an unknown command.
1408
+ #
1409
+ # User authors path data in 1-indexed slide cells, matching every
1410
+ # other shape coord (`<line x1>`, `<rect x>`, polyline points …).
1411
+ # We walk each command, accumulate every endpoint and control
1412
+ # point as a pixel coord (over-estimates the bbox slightly for
1413
+ # cubic / quadratic curves — control points commonly sit outside
1414
+ # the visible curve — but that's safe), and re-emit `d` with the
1415
+ # numbers translated. Absolute positions take the `(N-1)*cell_dim`
1416
+ # offset; relative deltas use the unshifted `N*cell_dim`. Arc `A`
1417
+ # / `a` rx & ry scale anisotropically (rx by cell_w, ry by
1418
+ # cell_h) so an author who wants a circular arc has to use equal
1419
+ # rx/ry written in cell-widths of *each* axis — same as the
1420
+ # `<circle>` vs `<ellipse>` distinction.
1421
+ PATH_TOKEN_RE = /[A-Za-z]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
1422
+ def path_to_pixels(d_attr, cell_w, cell_h)
1423
+ return nil if d_attr.nil?
1424
+ tokens = d_attr.to_s.scan(PATH_TOKEN_RE)
1425
+ return nil if tokens.empty?
1426
+
1427
+ xs = []
1428
+ ys = []
1429
+ cx = 0.0
1430
+ cy = 0.0
1431
+ sx = 0.0 # subpath start (for Z)
1432
+ sy = 0.0
1433
+ out = []
1434
+ cmd = nil
1435
+ i = 0
1436
+
1437
+ pos_x = ->(x) { (x - 1) * cell_w }
1438
+ pos_y = ->(y) { (y - 1) * cell_h }
1439
+ del_x = ->(x) { x * cell_w }
1440
+ del_y = ->(y) { y * cell_h }
1441
+
1442
+ while i < tokens.size
1443
+ tok = tokens[i]
1444
+ if tok.match?(/\A[A-Za-z]\z/)
1445
+ cmd = tok
1446
+ out << cmd
1447
+ i += 1
1448
+ if cmd == 'Z' || cmd == 'z'
1449
+ cx, cy = sx, sy
1450
+ end
1451
+ next
1452
+ end
1453
+
1454
+ case cmd
1455
+ when 'M', 'L', 'T'
1456
+ x = tokens[i].to_f; y = tokens[i + 1].to_f
1457
+ cx, cy = x, y
1458
+ sx, sy = cx, cy if cmd == 'M'
1459
+ px = pos_x.call(cx); py = pos_y.call(cy)
1460
+ xs << px; ys << py
1461
+ out << "#{fnum(px)} #{fnum(py)}"
1462
+ i += 2
1463
+ cmd = 'L' if cmd == 'M'
1464
+ when 'm', 'l', 't'
1465
+ dx = tokens[i].to_f; dy = tokens[i + 1].to_f
1466
+ cx += dx; cy += dy
1467
+ sx, sy = cx, cy if cmd == 'm'
1468
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1469
+ out << "#{fnum(del_x.call(dx))} #{fnum(del_y.call(dy))}"
1470
+ i += 2
1471
+ cmd = 'l' if cmd == 'm'
1472
+ when 'H'
1473
+ x = tokens[i].to_f
1474
+ cx = x
1475
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1476
+ out << fnum(pos_x.call(cx))
1477
+ i += 1
1478
+ when 'h'
1479
+ dx = tokens[i].to_f
1480
+ cx += dx
1481
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1482
+ out << fnum(del_x.call(dx))
1483
+ i += 1
1484
+ when 'V'
1485
+ y = tokens[i].to_f
1486
+ cy = y
1487
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1488
+ out << fnum(pos_y.call(cy))
1489
+ i += 1
1490
+ when 'v'
1491
+ dy = tokens[i].to_f
1492
+ cy += dy
1493
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1494
+ out << fnum(del_y.call(dy))
1495
+ i += 1
1496
+ when 'C'
1497
+ nums = (0..5).map { |k| tokens[i + k].to_f }
1498
+ # Three (x, y) pairs: two control points then endpoint.
1499
+ pairs = nums.each_slice(2).to_a
1500
+ pairs.each { |x, y| xs << pos_x.call(x); ys << pos_y.call(y) }
1501
+ cx, cy = pairs.last
1502
+ out << pairs.map { |x, y| "#{fnum(pos_x.call(x))} #{fnum(pos_y.call(y))}" }.join(' ')
1503
+ i += 6
1504
+ when 'c'
1505
+ nums = (0..5).map { |k| tokens[i + k].to_f }
1506
+ pairs_rel = nums.each_slice(2).to_a
1507
+ pairs_abs = pairs_rel.map { |dx, dy| [cx + dx, cy + dy] }
1508
+ pairs_abs.each { |x, y| xs << pos_x.call(x); ys << pos_y.call(y) }
1509
+ cx, cy = pairs_abs.last
1510
+ out << pairs_rel.map { |dx, dy| "#{fnum(del_x.call(dx))} #{fnum(del_y.call(dy))}" }.join(' ')
1511
+ i += 6
1512
+ when 'S', 'Q'
1513
+ nums = (0..3).map { |k| tokens[i + k].to_f }
1514
+ pairs = nums.each_slice(2).to_a
1515
+ pairs.each { |x, y| xs << pos_x.call(x); ys << pos_y.call(y) }
1516
+ cx, cy = pairs.last
1517
+ out << pairs.map { |x, y| "#{fnum(pos_x.call(x))} #{fnum(pos_y.call(y))}" }.join(' ')
1518
+ i += 4
1519
+ when 's', 'q'
1520
+ nums = (0..3).map { |k| tokens[i + k].to_f }
1521
+ pairs_rel = nums.each_slice(2).to_a
1522
+ pairs_abs = pairs_rel.map { |dx, dy| [cx + dx, cy + dy] }
1523
+ pairs_abs.each { |x, y| xs << pos_x.call(x); ys << pos_y.call(y) }
1524
+ cx, cy = pairs_abs.last
1525
+ out << pairs_rel.map { |dx, dy| "#{fnum(del_x.call(dx))} #{fnum(del_y.call(dy))}" }.join(' ')
1526
+ i += 4
1527
+ when 'A'
1528
+ rx = tokens[i].to_f; ry = tokens[i + 1].to_f
1529
+ rot = tokens[i + 2].to_f
1530
+ la = tokens[i + 3].to_i
1531
+ sw_flag = tokens[i + 4].to_i
1532
+ x = tokens[i + 5].to_f; y = tokens[i + 6].to_f
1533
+ cx, cy = x, y
1534
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1535
+ out << "#{fnum(del_x.call(rx))} #{fnum(del_y.call(ry))} #{fnum(rot)} #{la} #{sw_flag} #{fnum(pos_x.call(x))} #{fnum(pos_y.call(y))}"
1536
+ i += 7
1537
+ when 'a'
1538
+ rx = tokens[i].to_f; ry = tokens[i + 1].to_f
1539
+ rot = tokens[i + 2].to_f
1540
+ la = tokens[i + 3].to_i
1541
+ sw_flag = tokens[i + 4].to_i
1542
+ dx = tokens[i + 5].to_f; dy = tokens[i + 6].to_f
1543
+ cx += dx; cy += dy
1544
+ xs << pos_x.call(cx); ys << pos_y.call(cy)
1545
+ out << "#{fnum(del_x.call(rx))} #{fnum(del_y.call(ry))} #{fnum(rot)} #{la} #{sw_flag} #{fnum(del_x.call(dx))} #{fnum(del_y.call(dy))}"
1546
+ i += 7
1547
+ else
1548
+ return nil
1549
+ end
1550
+ end
1551
+
1552
+ return nil if xs.empty?
1553
+ {bbox_x: xs.min, bbox_y: ys.min,
1554
+ bbox_w: xs.max - xs.min, bbox_h: ys.max - ys.min,
1555
+ d: out.join(' ')}
1556
+ end
1557
+
1558
+ # Compose the SVG document shipped to the terminal. The viewBox is
1559
+ # in pixels and matches the cell-quantized rasterization footprint
1560
+ # exactly, so 1 user unit = 1 pixel — no anisotropic stretching,
1561
+ # circles render circular, stroke widths reproduce the exact pixel
1562
+ # thickness we computed. Default paint colors are spelled out as
1563
+ # `white` (not `currentColor`) so the SVG renders correctly even
1564
+ # if Echoes doesn't propagate the root `color=` to children.
1565
+ def build_shape_svg(kind, attrs, geom, sw_px, vb_x, vb_y, vb_w, vb_h)
1566
+ vb = "#{fnum(vb_x)} #{fnum(vb_y)} #{fnum(vb_w)} #{fnum(vb_h)}"
1567
+ shape_xml = shape_element(kind, attrs, geom, sw_px)
1568
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="#{vb}">#{shape_xml}</svg>)
1569
+ end
1570
+
1571
+ def shape_element(kind, attrs, geom, sw_px)
1572
+ paint = shape_paint_attrs(kind, attrs, sw_px)
1573
+ case kind
1574
+ when :rect
1575
+ rx = geom[:rx] ? %( rx="#{fnum(geom[:rx])}") : ''
1576
+ ry = geom[:ry] ? %( ry="#{fnum(geom[:ry])}") : ''
1577
+ %(<rect x="#{fnum(geom[:x])}" y="#{fnum(geom[:y])}" width="#{fnum(geom[:w])}" height="#{fnum(geom[:h])}"#{rx}#{ry}#{paint}/>)
1578
+ when :circle
1579
+ %(<circle cx="#{fnum(geom[:cx])}" cy="#{fnum(geom[:cy])}" r="#{fnum(geom[:r])}"#{paint}/>)
1580
+ when :ellipse
1581
+ %(<ellipse cx="#{fnum(geom[:cx])}" cy="#{fnum(geom[:cy])}" rx="#{fnum(geom[:rx])}" ry="#{fnum(geom[:ry])}"#{paint}/>)
1582
+ when :line
1583
+ %(<line x1="#{fnum(geom[:x1])}" y1="#{fnum(geom[:y1])}" x2="#{fnum(geom[:x2])}" y2="#{fnum(geom[:y2])}"#{paint}/>)
1584
+ when :arrow
1585
+ # Stem (line) + filled triangular head. The head's fill
1586
+ # defaults to the stem's stroke color so the two read as one
1587
+ # arrow; an explicit `fill="..."` on the tag overrides only
1588
+ # the head, giving a two-tone arrow if that's what you want.
1589
+ stem = %(<line x1="#{fnum(geom[:x1])}" y1="#{fnum(geom[:y1])}" x2="#{fnum(geom[:x2])}" y2="#{fnum(geom[:y2])}"#{paint}/>)
1590
+ tip, b1, b2 = geom[:head]
1591
+ head_pts = "#{fnum(tip[0])},#{fnum(tip[1])} #{fnum(b1[0])},#{fnum(b1[1])} #{fnum(b2[0])},#{fnum(b2[1])}"
1592
+ head_fill = normalize_svg_color(attrs['fill'] || attrs['stroke'] || 'white')
1593
+ stem + %(<polygon points="#{head_pts}" fill="#{head_fill}"/>)
1594
+ when :polyline
1595
+ %(<polyline points="#{points_attr(geom[:points])}"#{paint}/>)
1596
+ when :polygon
1597
+ %(<polygon points="#{points_attr(geom[:points])}"#{paint}/>)
1598
+ when :path
1599
+ # `d` has already been rewritten into pixel space by
1600
+ # path_to_pixels — stash on the geom hash so the renderer
1601
+ # never needs to know about cell coordinates here.
1602
+ %(<path d="#{geom[:d]}"#{paint}/>)
1603
+ end
1604
+ end
1605
+
1606
+ # Compute the arrowhead triangle for an `<arrow>` shape and merge
1607
+ # the result back into the geom hash. Head dimensions scale with
1608
+ # stroke width: length 4× sw_px, width 3× sw_px — typical SVG
1609
+ # marker proportions. Bbox grows to include the head's vertices
1610
+ # so cell quantization reserves room for the triangle.
1611
+ def expand_arrow_geometry(geom, sw_px)
1612
+ x1 = geom[:x1].to_f
1613
+ y1 = geom[:y1].to_f
1614
+ x2 = geom[:x2].to_f
1615
+ y2 = geom[:y2].to_f
1616
+ dx = x2 - x1
1617
+ dy = y2 - y1
1618
+ len = Math.sqrt(dx * dx + dy * dy)
1619
+ return geom if len < 1e-6
1620
+
1621
+ head_len = sw_px * 4.0
1622
+ head_wid = sw_px * 3.0
1623
+ ux = dx / len
1624
+ uy = dy / len
1625
+ base_cx = x2 - ux * head_len
1626
+ base_cy = y2 - uy * head_len
1627
+ # Perpendicular unit vector (rotate (ux,uy) by 90°).
1628
+ perp_x = -uy * head_wid / 2.0
1629
+ perp_y = ux * head_wid / 2.0
1630
+ tip = [x2, y2]
1631
+ b1 = [base_cx + perp_x, base_cy + perp_y]
1632
+ b2 = [base_cx - perp_x, base_cy - perp_y]
1633
+
1634
+ xs = [x1, x2, b1[0], b2[0]]
1635
+ ys = [y1, y2, b1[1], b2[1]]
1636
+ geom.merge(
1637
+ bbox_x: xs.min,
1638
+ bbox_y: ys.min,
1639
+ bbox_w: xs.max - xs.min,
1640
+ bbox_h: ys.max - ys.min,
1641
+ head: [tip, b1, b2]
1642
+ )
1643
+ end
1644
+
1645
+ # `stroke-width` is overridden with the renderer-computed pixel
1646
+ # value so the user's "0.3" cell-widths becomes the actual pixel
1647
+ # count we padded the bbox by. The user's other paint attrs pass
1648
+ # through; sensible defaults fill in fill/stroke if unset. `fill`
1649
+ # and `stroke` get normalized through CSS_NAMED_COLORS so that
1650
+ # SVG names Echoes' fast path doesn't know natively (tomato,
1651
+ # gold, lavender, …) become `#rrggbb` before the SVG is shipped.
1652
+ def shape_paint_attrs(kind, attrs, sw_px)
1653
+ defaults =
1654
+ if open_shape?(kind)
1655
+ {'fill' => 'none', 'stroke' => 'white'}
1656
+ else
1657
+ {'fill' => 'white'}
1658
+ end
1659
+ out = defaults.merge(attrs.slice(*SHAPE_PAINT_ATTRS))
1660
+ out['fill'] = normalize_svg_color(out['fill']) if out['fill']
1661
+ out['stroke'] = normalize_svg_color(out['stroke']) if out['stroke']
1662
+ # Replace stroke-width (if present, or implicit on open shapes)
1663
+ # with the pixel value matching our bbox padding.
1664
+ if out['stroke'] && out['stroke'] != 'none' && sw_px > 0
1665
+ out['stroke-width'] = fnum(sw_px)
1666
+ else
1667
+ out.delete('stroke-width')
1668
+ end
1669
+ out.map { |k, v| %( #{k}="#{v}") }.join
1670
+ end
1671
+
1672
+ # Translate a named CSS color to `#rrggbb` so Echoes' SVG renderer
1673
+ # gets a hex code it always parses (its native named-color list
1674
+ # covers only ~22 of the 140 standard CSS names). Hex codes,
1675
+ # `rgb(...)` / `rgba(...)` functions, `none`, `currentColor`, and
1676
+ # `transparent` pass through unchanged; unknown names also pass
1677
+ # through (let Echoes decide).
1678
+ def normalize_svg_color(value)
1679
+ s = value.to_s
1680
+ return s if s.empty?
1681
+ return s if s.start_with?('#')
1682
+ return s if s.match?(/\A(?:rgb|rgba|hsl|hsla)\(/i)
1683
+ down = s.downcase
1684
+ return s if down == 'none' || down == 'currentcolor' || down == 'transparent'
1685
+ hex = CSS_NAMED_COLORS[down]
1686
+ hex ? "##{hex}" : s
1687
+ end
1688
+
1689
+ # Full set of CSS / SVG named colors — the same 147-color table the
1690
+ # browsers ship. We translate every name to `#rrggbb` before handing
1691
+ # the SVG to Echoes so the deck author can write `fill="tomato"`,
1692
+ # `stroke="lavender"`, etc. without thinking about which subset the
1693
+ # terminal's named-color parser happens to recognize.
1694
+ CSS_NAMED_COLORS = {
1695
+ 'aliceblue' => 'f0f8ff', 'antiquewhite' => 'faebd7', 'aqua' => '00ffff',
1696
+ 'aquamarine' => '7fffd4', 'azure' => 'f0ffff', 'beige' => 'f5f5dc',
1697
+ 'bisque' => 'ffe4c4', 'black' => '000000', 'blanchedalmond' => 'ffebcd',
1698
+ 'blue' => '0000ff', 'blueviolet' => '8a2be2', 'brown' => 'a52a2a',
1699
+ 'burlywood' => 'deb887', 'cadetblue' => '5f9ea0', 'chartreuse' => '7fff00',
1700
+ 'chocolate' => 'd2691e', 'coral' => 'ff7f50', 'cornflowerblue' => '6495ed',
1701
+ 'cornsilk' => 'fff8dc', 'crimson' => 'dc143c', 'cyan' => '00ffff',
1702
+ 'darkblue' => '00008b', 'darkcyan' => '008b8b', 'darkgoldenrod' => 'b8860b',
1703
+ 'darkgray' => 'a9a9a9', 'darkgrey' => 'a9a9a9', 'darkgreen' => '006400',
1704
+ 'darkkhaki' => 'bdb76b', 'darkmagenta' => '8b008b', 'darkolivegreen' => '556b2f',
1705
+ 'darkorange' => 'ff8c00', 'darkorchid' => '9932cc', 'darkred' => '8b0000',
1706
+ 'darksalmon' => 'e9967a', 'darkseagreen' => '8fbc8f', 'darkslateblue' => '483d8b',
1707
+ 'darkslategray' => '2f4f4f', 'darkslategrey' => '2f4f4f', 'darkturquoise' => '00ced1',
1708
+ 'darkviolet' => '9400d3', 'deeppink' => 'ff1493', 'deepskyblue' => '00bfff',
1709
+ 'dimgray' => '696969', 'dimgrey' => '696969', 'dodgerblue' => '1e90ff',
1710
+ 'firebrick' => 'b22222', 'floralwhite' => 'fffaf0', 'forestgreen' => '228b22',
1711
+ 'fuchsia' => 'ff00ff', 'gainsboro' => 'dcdcdc', 'ghostwhite' => 'f8f8ff',
1712
+ 'gold' => 'ffd700', 'goldenrod' => 'daa520', 'gray' => '808080',
1713
+ 'grey' => '808080', 'green' => '008000', 'greenyellow' => 'adff2f',
1714
+ 'honeydew' => 'f0fff0', 'hotpink' => 'ff69b4', 'indianred' => 'cd5c5c',
1715
+ 'indigo' => '4b0082', 'ivory' => 'fffff0', 'khaki' => 'f0e68c',
1716
+ 'lavender' => 'e6e6fa', 'lavenderblush' => 'fff0f5', 'lawngreen' => '7cfc00',
1717
+ 'lemonchiffon' => 'fffacd', 'lightblue' => 'add8e6', 'lightcoral' => 'f08080',
1718
+ 'lightcyan' => 'e0ffff', 'lightgoldenrodyellow' => 'fafad2', 'lightgray' => 'd3d3d3',
1719
+ 'lightgrey' => 'd3d3d3', 'lightgreen' => '90ee90', 'lightpink' => 'ffb6c1',
1720
+ 'lightsalmon' => 'ffa07a', 'lightseagreen' => '20b2aa', 'lightskyblue' => '87cefa',
1721
+ 'lightslategray' => '778899', 'lightslategrey' => '778899', 'lightsteelblue' => 'b0c4de',
1722
+ 'lightyellow' => 'ffffe0', 'lime' => '00ff00', 'limegreen' => '32cd32',
1723
+ 'linen' => 'faf0e6', 'magenta' => 'ff00ff', 'maroon' => '800000',
1724
+ 'mediumaquamarine' => '66cdaa', 'mediumblue' => '0000cd', 'mediumorchid' => 'ba55d3',
1725
+ 'mediumpurple' => '9370db', 'mediumseagreen' => '3cb371', 'mediumslateblue' => '7b68ee',
1726
+ 'mediumspringgreen' => '00fa9a', 'mediumturquoise' => '48d1cc', 'mediumvioletred' => 'c71585',
1727
+ 'midnightblue' => '191970', 'mintcream' => 'f5fffa', 'mistyrose' => 'ffe4e1',
1728
+ 'moccasin' => 'ffe4b5', 'navajowhite' => 'ffdead', 'navy' => '000080',
1729
+ 'oldlace' => 'fdf5e6', 'olive' => '808000', 'olivedrab' => '6b8e23',
1730
+ 'orange' => 'ffa500', 'orangered' => 'ff4500', 'orchid' => 'da70d6',
1731
+ 'palegoldenrod' => 'eee8aa', 'palegreen' => '98fb98', 'paleturquoise' => 'afeeee',
1732
+ 'palevioletred' => 'db7093', 'papayawhip' => 'ffefd5', 'peachpuff' => 'ffdab9',
1733
+ 'peru' => 'cd853f', 'pink' => 'ffc0cb', 'plum' => 'dda0dd',
1734
+ 'powderblue' => 'b0e0e6', 'purple' => '800080', 'rebeccapurple' => '663399',
1735
+ 'red' => 'ff0000', 'rosybrown' => 'bc8f8f', 'royalblue' => '4169e1',
1736
+ 'saddlebrown' => '8b4513', 'salmon' => 'fa8072', 'sandybrown' => 'f4a460',
1737
+ 'seagreen' => '2e8b57', 'seashell' => 'fff5ee', 'sienna' => 'a0522d',
1738
+ 'silver' => 'c0c0c0', 'skyblue' => '87ceeb', 'slateblue' => '6a5acd',
1739
+ 'slategray' => '708090', 'slategrey' => '708090', 'snow' => 'fffafa',
1740
+ 'springgreen' => '00ff7f', 'steelblue' => '4682b4', 'tan' => 'd2b48c',
1741
+ 'teal' => '008080', 'thistle' => 'd8bfd8', 'tomato' => 'ff6347',
1742
+ 'turquoise' => '40e0d0', 'violet' => 'ee82ee', 'wheat' => 'f5deb3',
1743
+ 'white' => 'ffffff', 'whitesmoke' => 'f5f5f5', 'yellow' => 'ffff00',
1744
+ 'yellowgreen' => '9acd32'
1745
+ }.freeze
1746
+
1747
+ def points_attr(points)
1748
+ points.map { |x, y| "#{fnum(x)},#{fnum(y)}" }.join(' ')
1749
+ end
1750
+
1751
+ # Compact float formatting for SVG: drop trailing ".0" on integral
1752
+ # values, otherwise emit up to three decimals. Keeps the payload
1753
+ # short and the cache key stable.
1754
+ def fnum(n)
1755
+ f = n.to_f
1756
+ i = f.to_i
1757
+ f == i ? i.to_s : format('%.3f', f)
1758
+ end
1759
+
1760
+ # Resolve a coordinate string against the dimension it indexes.
1761
+ # Accepts four suffix forms; the bare-number case picks between cells
1762
+ # and pixels based on `default_unit:` so each tag can pick the unit
1763
+ # that matches its native vocabulary (`<at>` → cells, `<img>` → px).
1764
+ #
1765
+ # "50%" → halfway along `max` cells
1766
+ # "100px"→ 100 pixels (anchor cell + sub-cell pixel offset)
1767
+ # "10c" → cell 10 (1-based; explicit)
1768
+ # "10" → cell 10 OR 10 px (per default_unit)
1769
+ #
1770
+ # Pixel forms compute both the *anchor cell* (the 1-based cell
1771
+ # containing that pixel) and the pixel remainder *within* that cell
1772
+ # — useful for Kitty Graphics' `X=`/`Y=` sub-cell offsets, which
1773
+ # give true 1-px positioning instead of snap-to-cell.
1774
+ #
1775
+ # By default this returns just the anchor cell (preserving the old
1776
+ # `<at>`/`<slot>` shape). Pass `with_offset: true` to get back
1777
+ # `[cell, px_offset_within_anchor]` instead — `<img>` uses the pair
1778
+ # form because images can place at any sub-cell pixel; text and
1779
+ # slots stay cell-snapped because they have to.
1780
+ #
1781
+ # `cell_px` is required for any px form; if it's nil and a px-form
1782
+ # value is given, returns nil. Out-of-range values clamp into
1783
+ # [1, max] and the sub-cell offset is zeroed if the clamp moved the
1784
+ # cell (the placement is going off-screen anyway, no point keeping
1785
+ # a stale offset). Returns nil when the input is missing or
1786
+ # unparseable so the renderer skips silently.
1787
+ def resolve_at_coord(raw, max, cell_px: nil, default_unit: :cell, with_offset: false)
1788
+ return nil if raw.nil?
1789
+
1790
+ s = raw.to_s.strip
1791
+ return nil if s.empty?
1792
+
1793
+ cell = nil
1794
+ px_off = 0
1795
+
1796
+ if s.end_with?('%')
1797
+ pct = s.chomp('%').to_f
1798
+ cell = (pct / 100.0 * max).round
1799
+ elsif (m = s.match(/\A(-?\d+(?:\.\d+)?)px\z/))
1800
+ return nil unless cell_px && cell_px > 0
1801
+ px = m[1].to_f
1802
+ cell = (px / cell_px).floor + 1
1803
+ px_off = (px - (cell - 1) * cell_px).to_i
1804
+ elsif (m = s.match(/\A(-?\d+)c\z/))
1805
+ cell = m[1].to_i
1806
+ elsif s =~ /\A-?\d+(?:\.\d+)?\z/
1807
+ if default_unit == :px
1808
+ return nil unless cell_px && cell_px > 0
1809
+ px = s.to_f
1810
+ cell = (px / cell_px).floor + 1
1811
+ px_off = (px - (cell - 1) * cell_px).to_i
1812
+ else
1813
+ cell = s.to_i
1814
+ end
1815
+ end
1816
+ return nil if cell.nil?
1817
+
1818
+ clamped = cell.clamp(1, max)
1819
+ px_off = 0 if clamped != cell
1820
+ cell = clamped
1821
+
1822
+ with_offset ? [cell, px_off] : cell
1823
+ end
1824
+
140
1825
  # Bottom-row progress indicator (Rabbit-style):
141
1826
  #
142
1827
  # 1 🐢 🐇 9
143
1828
  # └ current slide # └ elapsed time └ slide progress └ goal (total slides)
144
1829
  #
145
1830
  # The anchor numbers (current at the left, total at the right) sit on the
146
- # very bottom row; the emojis render at OSC 66 scale 2 and are anchored at
147
- # row `h-1` so their bottom half lands on row `h` next to the numbers,
148
- # making them visibly twice as large as the labels without needing more
149
- # vertical screen real-estate. The turtle is hidden when
150
- # `theme.rabbit.duration` is unset / unparseable. `flip=h` mirrors each
151
- # glyph horizontally on terminals that honor it (Echoes); others ignore
152
- # the parameter and the emojis face left.
153
- EMOJI_RUNNER_CELLS = 4 # 🐇/🐢 are 2 source cells wide, rendered at s=2 → 4 cells
1831
+ # very bottom row; the runner emojis live at theme.counter[:rabbit] /
1832
+ # [:turtle] as raw escape sequences (the default is an OSC 7772 multicell
1833
+ # at scale 2 with flip=h, so they face right; non-Echoes terminals drop
1834
+ # the OSC and the runner glyphs don't appear). The strings are emitted
1835
+ # at row `h-1` so their bottom half lands on row `h` next to the anchor
1836
+ # numbers — twice as tall as the labels without needing more rows.
1837
+ EMOJI_RUNNER_CELLS = 4 # default 🐇/🐢 at OSC 66 s=2 occupy 4 cells; override theme keys at your peril
154
1838
 
155
1839
  def draw_runner_bar(h, w, current, total, started_at)
156
1840
  left = (current + 1).to_s
157
1841
  right = total.to_s
158
- track_left = left.size + 2 # 1 cell gap after the left number
159
- track_right = w - right.size - 1 # 1 cell gap before the right number
160
- return if track_right - track_left < EMOJI_RUNNER_CELLS
1842
+ track = runner_track(w, current, total)
1843
+ return unless track
1844
+ track_left, track_right = track
1845
+
1846
+ rabbit_row = [h - 1, 1].max
1847
+
1848
+ # Wipe the emoji track before redrawing so a previous turtle
1849
+ # position (left over from the last runner-bar thread tick)
1850
+ # doesn't ghost beside the new one. Row h has the anchor
1851
+ # numbers — they're re-written right below so they don't need
1852
+ # a wipe.
1853
+ @terminal.move_to(rabbit_row, track_left)
1854
+ @terminal.write ' ' * (track_right - track_left + EMOJI_RUNNER_CELLS)
161
1855
 
1856
+ open_seq = counter_color_open
162
1857
  @terminal.move_to(h, 1)
163
- @terminal.write "#{ANSI[:dim]}#{left}#{ANSI[:reset]}"
1858
+ @terminal.write "#{open_seq}#{left}#{ANSI[:reset]}"
164
1859
  @terminal.move_to(h, w - right.size + 1)
165
- @terminal.write "#{ANSI[:dim]}#{right}#{ANSI[:reset]}"
1860
+ @terminal.write "#{open_seq}#{right}#{ANSI[:reset]}"
166
1861
 
167
- rabbit_row = [h - 1, 1].max
1862
+ counter = @theme&.counter || {}
168
1863
  rabbit_col = runner_col(current, [total - 1, 1].max, track_left, track_right)
169
1864
  @terminal.move_to(rabbit_row, rabbit_col)
170
- @terminal.write KittyText.sized('🐇', s: 2, flip: 'h')
1865
+ @terminal.write counter[:rabbit].to_s
171
1866
 
172
- duration_s = Theme.parse_duration(@theme.rabbit[:duration])
1867
+ duration_s = counter_duration
173
1868
  return unless started_at && duration_s && duration_s.positive?
174
1869
 
175
1870
  elapsed = Time.now - started_at
@@ -177,7 +1872,17 @@ module Przn
177
1872
  span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
178
1873
  turtle_col = track_left + (frac * [span, 0].max).round
179
1874
  @terminal.move_to(rabbit_row, turtle_col)
180
- @terminal.write KittyText.sized('🐢', s: 2, flip: 'h')
1875
+ @terminal.write counter[:turtle].to_s
1876
+ end
1877
+
1878
+ # `[track_left, track_right]` columns of the runner track, or nil
1879
+ # when the terminal is too narrow to fit an emoji between the anchor
1880
+ # numbers.
1881
+ def runner_track(w, current, total)
1882
+ track_left = (current + 1).to_s.size + 2 # 1 cell gap after the left number
1883
+ track_right = w - total.to_s.size - 1 # 1 cell gap before the right number
1884
+ return nil if track_right - track_left < EMOJI_RUNNER_CELLS
1885
+ [track_left, track_right]
181
1886
  end
182
1887
 
183
1888
  # Linear-interpolate a runner's column inside the track. `step` is 0..max
@@ -191,42 +1896,54 @@ module Przn
191
1896
  track_left + (step.to_f / max * span).round
192
1897
  end
193
1898
 
194
- def render_heading(block, width, row)
1899
+ def render_heading(block, width, row, align: nil)
195
1900
  text = block[:content]
196
1901
 
197
1902
  if block[:level] == 1
198
1903
  title = @theme.title || {}
199
- scale = (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
200
- face = title[:family]
201
- color = title[:color]
1904
+ scale = slot_scale || (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
1905
+ face = slot_family || title[:family]
1906
+ color = slot_color || title[:color]
202
1907
  max_w = max_text_width(width, 0, scale)
203
1908
  segments = Parser.parse_inline(text)
204
1909
  wrapped = wrap_segments(segments, max_w, scale)
205
1910
 
206
1911
  wrapped.each do |line_segs|
207
- vis = segments_visible_cells(line_segs, scale)
208
- pad = [(width - vis) / 2, 0].max
209
- @terminal.move_to(row, pad + 1)
210
- @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2, default_color: color)}#{ANSI[:reset]}"
1912
+ if server_centered_eligible?(line_segs, face, align, width, scale)
1913
+ col, body = server_centered_emit(line_segs.first[1], scale, face, width)
1914
+ body = "#{color_code(color)}#{body}#{ANSI[:reset]}" if color && !color.to_s.empty?
1915
+ term_move(row, col)
1916
+ @terminal.write "#{ANSI[:bold]}#{body}#{ANSI[:reset]}"
1917
+ else
1918
+ vis = segments_visible_cells(line_segs, scale)
1919
+ pad = compute_pad(width, vis, align)
1920
+ term_move(row, pad + 1)
1921
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2, default_color: color)}#{ANSI[:reset]}"
1922
+ end
211
1923
  row += scale
212
1924
  end
213
- row + 4
1925
+ # `+ 4` is the standard "leave a bit of breathing room
1926
+ # below the heading before the next block." For the
1927
+ # measure_blocks_content_height dry-run, skip it on the
1928
+ # FINAL block so `y: center` on a single-h1 slot centres
1929
+ # the actual text, not the text plus trailing padding.
1930
+ @measuring_last ? row : row + 4
214
1931
  else
215
1932
  left = content_left(width)
216
1933
  prefix = @theme.bullet[:text]
217
1934
  prefix_w = display_width(prefix)
218
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
1935
+ max_w = max_text_width(width, left, body_scale) - prefix_w
219
1936
  segments = Parser.parse_inline(text)
220
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
1937
+ wrapped = wrap_segments(segments, max_w, body_scale)
221
1938
 
222
1939
  wrapped.each_with_index do |line_segs, li|
223
- @terminal.move_to(row, left)
1940
+ term_move(row, left)
224
1941
  if li == 0
225
- @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
1942
+ @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, body_scale)}"
226
1943
  else
227
- @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
1944
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: body_scale)}#{render_segments_scaled(line_segs, body_scale)}"
228
1945
  end
229
- row += DEFAULT_SCALE
1946
+ row += body_scale
230
1947
  end
231
1948
  row
232
1949
  end
@@ -234,118 +1951,326 @@ module Przn
234
1951
 
235
1952
  def render_paragraph(block, width, row, align: nil)
236
1953
  text = block[:content]
237
- scale = max_inline_scale(text) || DEFAULT_SCALE
1954
+ # Two scales, not one:
1955
+ # - `base_scale` is the cell size for plain (unsized) text.
1956
+ # Always body_scale (or the active slot's override) — a
1957
+ # `<font size=xx-large>` somewhere in the paragraph must
1958
+ # NOT pull body text up with it.
1959
+ # - row advance happens per line and uses each line's own
1960
+ # `max_segment_scale` so a tall span only inflates the row
1961
+ # it actually appears on.
1962
+ base_scale = slot_scale || body_scale
1963
+ face = slot_family || @theme.font[:family]
1964
+ color = slot_color || @theme.font[:color]
238
1965
  left = content_left(width)
239
1966
 
240
1967
  if align
241
- vis = visible_width_scaled(text, scale)
1968
+ vis = visible_width_scaled(text, base_scale)
242
1969
  left = compute_pad(width, vis, align)
243
1970
  end
244
1971
 
245
- max_w = max_text_width(width, left, scale)
1972
+ max_w = max_text_width(width, left, base_scale)
246
1973
  segments = Parser.parse_inline(text)
247
- wrapped = wrap_segments(segments, max_w, scale)
1974
+ wrapped = wrap_segments(segments, max_w, base_scale)
248
1975
 
249
1976
  wrapped.each do |line_segs|
250
- @terminal.move_to(row, left + 1)
251
- @terminal.write render_segments_scaled(line_segs, scale)
252
- row += scale
1977
+ line_h = max_segment_scale(line_segs, base_scale)
1978
+ if server_centered_eligible?(line_segs, face, align, width, base_scale)
1979
+ # Hand the centering to Echoes' halign + proportional
1980
+ # measurement so a Yu Gothic / Helvetica Neue / etc. line
1981
+ # lands at the true pixel center rather than the cell-grid
1982
+ # estimate compute_pad would use. Body color is wrapped
1983
+ # around the multicell the same way render_segments_scaled
1984
+ # would have done — otherwise this line falls through to
1985
+ # the terminal default fg and visibly drifts from the
1986
+ # theme.font.color / slot.color the rest of the deck uses.
1987
+ col, body = server_centered_emit(line_segs.first[1], base_scale, face, width)
1988
+ body = "#{color_code(color)}#{body}#{ANSI[:reset]}" if color && !color.to_s.empty?
1989
+ term_move(row, col)
1990
+ @terminal.write body
1991
+ else
1992
+ term_move(row, left + 1)
1993
+ @terminal.write render_segments_scaled(line_segs, base_scale, line_height: line_h)
1994
+ end
1995
+ row += line_h
253
1996
  end
254
1997
  row
255
1998
  end
256
1999
 
257
2000
  def render_code_block(block, width, row)
258
2001
  code_lines = block[:content].lines.map(&:chomp)
259
- return row + DEFAULT_SCALE if code_lines.empty?
2002
+ return row + body_scale if code_lines.empty?
2003
+
2004
+ # `theme.code` knobs (all optional):
2005
+ # family — OSC 66 f= face (Echoes only); falls back to font.family.
2006
+ # size — OSC 66 scale; falls back to body_scale.
2007
+ # color — default fg for tokens that the highlighter didn't
2008
+ # paint; falls back to terminal default.
2009
+ # bg — block background; falls back to the historical
2010
+ # dim gray (ANSI 256-color 236).
2011
+ cfg = @theme.code || {}
2012
+ # Per-block IAL on the fenced opener (```ruby {size=1}` etc.) wins
2013
+ # over theme.code so an author can shrink one long snippet without
2014
+ # touching the deck-wide default.
2015
+ block_attrs = block[:attrs] || {}
2016
+ size_raw = block_attrs[:size] || cfg[:size]
2017
+ scale = (size_raw && Parser::SIZE_SCALES[size_raw.to_s]) || body_scale
2018
+ face = block_attrs[:family] || cfg[:family] || @theme.font[:family]
2019
+ bg_sgr = code_bg_sgr(block_attrs[:bg] || cfg[:bg])
2020
+ default_fg = block_attrs[:color] || cfg[:color]
2021
+ default_fg_sgr = (default_fg && color_code(default_fg)) || ''
260
2022
 
261
2023
  left = content_left(width)
262
- max_content_w = max_text_width(width, left, DEFAULT_SCALE) - 4
2024
+ max_content_w = max_text_width(width, left, scale) - 4
263
2025
  max_len = code_lines.map { |l| display_width(l) }.max
264
2026
  box_content_w = [max_len, max_content_w].min
265
2027
 
266
- code_lines.each do |code_line|
267
- truncated = truncate_to_width(code_line, box_content_w)
268
- padded = pad_to_width(truncated, box_content_w)
269
- @terminal.move_to(row, left + 1)
270
- @terminal.write "#{ANSI[:gray_bg]}#{KittyText.sized(" #{padded} ", s: DEFAULT_SCALE)}#{ANSI[:reset]}"
271
- row += DEFAULT_SCALE
2028
+ highlighted = highlighted_code_lines(block, code_lines)
2029
+
2030
+ # Per-line highlight stepping (`{lines=1-2|3|4}` IAL): walk the
2031
+ # pipe-separated phase the slide's current step lands on; lines
2032
+ # outside that phase's Set get wrapped in OSC 7772 cell-alpha so
2033
+ # Echoes paints them dim. Non-Echoes terminals drop the OSC and
2034
+ # render every line at full opacity (documented limitation).
2035
+ active_lines = if block[:line_steps] && @current_step && @block_step
2036
+ phase = (@current_step - (@block_step[block] || 0)).clamp(0, block[:line_steps].size - 1)
2037
+ block[:line_steps][phase]
2038
+ end
2039
+
2040
+ highlighted.each_with_index do |line_tokens, idx|
2041
+ fitted, used_w = fit_tokens_to_width(line_tokens, box_content_w)
2042
+ pad_len = [box_content_w - used_w, 0].max
2043
+ dim = active_lines && !active_lines.include?(idx + 1)
2044
+ term_move(row, left + 1)
2045
+ @terminal.write bg_sgr
2046
+ @terminal.write default_fg_sgr unless default_fg_sgr.empty?
2047
+ @terminal.write "\e]7772;cell-alpha;0.35\a" if dim
2048
+ @terminal.write KittyText.sized(' ', s: scale, f: face)
2049
+ fitted.each do |color, value|
2050
+ if color && !value.empty?
2051
+ code = color_code(color)
2052
+ @terminal.write code unless code.empty?
2053
+ @terminal.write KittyText.sized(value, s: scale, f: face)
2054
+ # Reset just the fg so the bg stays in effect, then reopen
2055
+ # the theme-default fg (if any) so the next uncolored token
2056
+ # picks it up instead of the terminal default.
2057
+ unless code.empty?
2058
+ @terminal.write "\e[39m"
2059
+ @terminal.write default_fg_sgr unless default_fg_sgr.empty?
2060
+ end
2061
+ else
2062
+ @terminal.write KittyText.sized(value, s: scale, f: face) unless value.empty?
2063
+ end
2064
+ end
2065
+ @terminal.write KittyText.sized("#{' ' * pad_len} ", s: scale, f: face)
2066
+ @terminal.write "\e]7772;cell-alpha;1\a" if dim
2067
+ @terminal.write ANSI[:reset]
2068
+ row += scale
272
2069
  end
273
2070
 
274
2071
  row
275
2072
  end
276
2073
 
2074
+ # Resolve `theme.code.bg` to a background-color SGR. Accepts the
2075
+ # same forms as `color_code`: CSS named colors, the ANSI palette
2076
+ # names (red / cyan / …), and 6-digit hex (with or without `#`).
2077
+ # An unset / unrecognised value falls back to ANSI 256-color 236
2078
+ # — the dim gray code blocks have rendered with since v1.
2079
+ def code_bg_sgr(value)
2080
+ return ANSI[:gray_bg] if value.nil? || value.to_s.empty?
2081
+ c = value.to_s.sub(/\A#/, '')
2082
+ if (hex = CSS_NAMED_COLORS[c.downcase])
2083
+ r, g, b = hex.scan(/../).map { |h| h.to_i(16) }
2084
+ "\e[48;2;#{r};#{g};#{b}m"
2085
+ elsif (ansi_fg = Parser::NAMED_COLORS[c])
2086
+ # Translate the 30-37 / 90-97 fg codes to their 40-47 / 100-107
2087
+ # bg counterparts (the +10 offset is the standard ANSI rule).
2088
+ "\e[#{ansi_fg.to_i + 10}m"
2089
+ elsif c.match?(/\A[0-9a-fA-F]{6}\z/)
2090
+ r, g, b = c.scan(/../).map { |h| h.to_i(16) }
2091
+ "\e[48;2;#{r};#{g};#{b}m"
2092
+ else
2093
+ ANSI[:gray_bg]
2094
+ end
2095
+ end
2096
+
2097
+ # Tokenize fenced code through CodeHighlighter when a language is
2098
+ # set; fall back to a single uncolored token per line otherwise.
2099
+ # The returned shape is always `[[[color, value], …], …]` — one
2100
+ # outer array per code line, one inner pair per token, color is
2101
+ # nil for "default fg" runs.
2102
+ def highlighted_code_lines(block, code_lines)
2103
+ tokens = block[:language] && CodeHighlighter.highlight(
2104
+ block[:content], block[:language], theme: highlight_theme_name
2105
+ )
2106
+ return code_lines.map { |line| [[nil, line]] } unless tokens
2107
+
2108
+ group_tokens_by_line(tokens, code_lines.size)
2109
+ end
2110
+
2111
+ # Read the configured Rouge theme name from `theme.code.highlight`,
2112
+ # or nil when no theme is loaded or the key is unset (current ANSI
2113
+ # palette).
2114
+ def highlight_theme_name
2115
+ @theme && @theme.code && @theme.code[:highlight]
2116
+ end
2117
+
2118
+ # Walk a flat token stream and split values containing "\n" so each
2119
+ # line gets its own array. The trailing newline on a token is
2120
+ # dropped — render_code_block emits line-by-line and newlines are
2121
+ # implicit between rows.
2122
+ def group_tokens_by_line(tokens, expected_lines)
2123
+ lines = [[]]
2124
+ tokens.each do |color, value|
2125
+ parts = value.split("\n", -1)
2126
+ parts.each_with_index do |part, idx|
2127
+ lines.last << [color, part] unless part.empty?
2128
+ lines << [] if idx < parts.size - 1
2129
+ end
2130
+ end
2131
+ # Pop a trailing empty line that comes from the source's final \n.
2132
+ lines.pop if lines.last.empty? && lines.size > expected_lines
2133
+ lines
2134
+ end
2135
+
2136
+ # Walk tokens until the cumulative visible width hits `max_w`,
2137
+ # truncating the last partial token if needed. Returns the
2138
+ # fitted-token array and the total width it consumed (useful for
2139
+ # right-padding the line).
2140
+ def fit_tokens_to_width(line_tokens, max_w)
2141
+ out = []
2142
+ used = 0
2143
+ line_tokens.each do |color, value|
2144
+ avail = max_w - used
2145
+ break if avail <= 0
2146
+ w = display_width(value)
2147
+ if w <= avail
2148
+ out << [color, value]
2149
+ used += w
2150
+ else
2151
+ out << [color, truncate_to_width(value, avail)]
2152
+ used = max_w
2153
+ break
2154
+ end
2155
+ end
2156
+ [out, used]
2157
+ end
2158
+
277
2159
  def render_unordered_list(block, width, row)
278
2160
  left = content_left(width)
2161
+ # Per-item step gating only applies when the list is reached via
2162
+ # the slide-level walk that populated @block_step. Inside a group
2163
+ # (or any nested context that doesn't seed an entry) — match the
2164
+ # group's "no sub-steps in v1" rule and render every item.
2165
+ gated = @block_step && @block_step.key?(block)
2166
+ item_step = gated ? @block_step[block] : nil
279
2167
  block[:items].each do |item|
280
- depth = item[:depth] || 0
281
- indent = ' ' * depth
282
- prefix = "#{indent}#{@theme.bullet[:text]}"
283
- prefix_w = display_width(prefix)
284
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
285
-
286
- segments = Parser.parse_inline(item[:text])
287
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
288
-
289
- wrapped.each_with_index do |line_segs, li|
290
- @terminal.move_to(row, left)
291
- if li == 0
292
- @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
293
- else
294
- @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
2168
+ item_step += 1 if gated && item[:wait]
2169
+ with_item_visibility(item_step) do
2170
+ depth = item[:depth] || 0
2171
+ indent = ' ' * depth
2172
+ prefix = "#{indent}#{@theme.bullet[:text]}"
2173
+ prefix_w = display_width(prefix)
2174
+ # Plain text renders at body_scale; lines containing a sized
2175
+ # span (e.g. `<size=5>BIG</size>`) advance the row by that
2176
+ # span's height per-line. Mixing `body` and `BIG` on the same
2177
+ # line keeps body at body size — only the BIG span grows.
2178
+ base_scale = body_scale
2179
+ max_w = max_text_width(width, left, base_scale) - prefix_w
2180
+
2181
+ segments = Parser.parse_inline(item[:text])
2182
+ wrapped = wrap_segments(segments, max_w, base_scale)
2183
+
2184
+ wrapped.each_with_index do |line_segs, li|
2185
+ line_h = max_segment_scale(line_segs, base_scale)
2186
+ term_move(row, left)
2187
+ if li == 0
2188
+ @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, base_scale, line_height: line_h)}"
2189
+ else
2190
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: base_scale)}#{render_segments_scaled(line_segs, base_scale, line_height: line_h)}"
2191
+ end
2192
+ row += line_h
295
2193
  end
296
- row += DEFAULT_SCALE
2194
+ row += 1
297
2195
  end
298
- row += 1
299
2196
  end
300
2197
  row
301
2198
  end
302
2199
 
303
2200
  def render_ordered_list(block, width, row)
304
2201
  left = content_left(width)
2202
+ gated = @block_step && @block_step.key?(block)
2203
+ item_step = gated ? @block_step[block] : nil
305
2204
  block[:items].each_with_index do |item, i|
306
- depth = item[:depth] || 0
307
- indent = ' ' * depth
308
- prefix = "#{indent}#{i + 1}. "
309
- prefix_w = display_width(prefix)
310
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
311
-
312
- segments = Parser.parse_inline(item[:text])
313
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
314
-
315
- wrapped.each_with_index do |line_segs, li|
316
- @terminal.move_to(row, left)
317
- if li == 0
318
- @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
319
- else
320
- @terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
2205
+ item_step += 1 if gated && item[:wait]
2206
+ with_item_visibility(item_step) do
2207
+ depth = item[:depth] || 0
2208
+ indent = ' ' * depth
2209
+ prefix = "#{indent}#{i + 1}. "
2210
+ prefix_w = display_width(prefix)
2211
+ base_scale = body_scale
2212
+ max_w = max_text_width(width, left, base_scale) - prefix_w
2213
+
2214
+ segments = Parser.parse_inline(item[:text])
2215
+ wrapped = wrap_segments(segments, max_w, base_scale)
2216
+
2217
+ wrapped.each_with_index do |line_segs, li|
2218
+ line_h = max_segment_scale(line_segs, base_scale)
2219
+ term_move(row, left)
2220
+ if li == 0
2221
+ @terminal.write "#{KittyText.sized(prefix, s: base_scale)}#{render_segments_scaled(line_segs, base_scale, line_height: line_h)}"
2222
+ else
2223
+ @terminal.write "#{KittyText.sized(' ' * prefix_w, s: base_scale)}#{render_segments_scaled(line_segs, base_scale, line_height: line_h)}"
2224
+ end
2225
+ row += line_h
321
2226
  end
322
- row += DEFAULT_SCALE
2227
+ row += 1
323
2228
  end
324
- row += 1
325
2229
  end
326
2230
  row
327
2231
  end
328
2232
 
2233
+ # Run the block with @terminal swapped to a NullTerm if `item_step`
2234
+ # is past the current reveal — the per-item parallel of
2235
+ # `suppressed_render`. Items past the current step still execute
2236
+ # their render path so `row` advances naturally (reserving layout
2237
+ # space), but no bytes reach the real terminal. A nil `item_step`
2238
+ # disables gating entirely (used when the surrounding block isn't
2239
+ # reached via the slide-level step walk).
2240
+ def with_item_visibility(item_step)
2241
+ if item_step && @current_step && item_step > @current_step
2242
+ real = @terminal
2243
+ @terminal = NullTerm.new(real)
2244
+ begin
2245
+ yield
2246
+ ensure
2247
+ @terminal = real
2248
+ end
2249
+ else
2250
+ yield
2251
+ end
2252
+ end
2253
+
329
2254
  def render_definition_list(block, width, row)
330
2255
  left = content_left(width)
331
- max_w = max_text_width(width, left, DEFAULT_SCALE)
2256
+ max_w = max_text_width(width, left, body_scale)
332
2257
 
333
2258
  segments = Parser.parse_inline(block[:term])
334
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
2259
+ wrapped = wrap_segments(segments, max_w, body_scale)
335
2260
  wrapped.each do |line_segs|
336
- @terminal.move_to(row, left)
337
- @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
338
- row += DEFAULT_SCALE
2261
+ term_move(row, left)
2262
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, body_scale)}#{ANSI[:reset]}"
2263
+ row += body_scale
339
2264
  end
340
2265
 
341
2266
  def_max_w = [max_w - 4, 1].max
342
2267
  block[:definition].each_line do |line|
343
2268
  segments = Parser.parse_inline(line.chomp)
344
- wrapped = wrap_segments(segments, def_max_w, DEFAULT_SCALE)
2269
+ wrapped = wrap_segments(segments, def_max_w, body_scale)
345
2270
  wrapped.each do |line_segs|
346
- @terminal.move_to(row, left + 4)
347
- @terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
348
- row += DEFAULT_SCALE
2271
+ term_move(row, left + 4)
2272
+ @terminal.write render_segments_scaled(line_segs, body_scale)
2273
+ row += body_scale
349
2274
  end
350
2275
  end
351
2276
  row
@@ -355,18 +2280,18 @@ module Przn
355
2280
  left = content_left(width)
356
2281
  prefix = '| '
357
2282
  prefix_w = display_width(prefix)
358
- max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
2283
+ max_w = max_text_width(width, left + 1, body_scale) - prefix_w
359
2284
 
360
2285
  block[:content].each_line do |line|
361
2286
  text = line.chomp
362
2287
  segments = [[:text, text]]
363
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
2288
+ wrapped = wrap_segments(segments, max_w, body_scale)
364
2289
 
365
2290
  wrapped.each_with_index do |line_segs, li|
366
- @terminal.move_to(row, left + 1)
2291
+ term_move(row, left + 1)
367
2292
  p = li == 0 ? prefix : ' ' * prefix_w
368
- @terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
369
- row += DEFAULT_SCALE
2293
+ @terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: body_scale)}#{render_segments_scaled(line_segs, body_scale)}#{ANSI[:reset]}"
2294
+ row += body_scale
370
2295
  end
371
2296
  end
372
2297
  row
@@ -385,78 +2310,200 @@ module Przn
385
2310
  all_rows.each_with_index do |cells, ri|
386
2311
  next unless cells
387
2312
 
388
- @terminal.move_to(row, left)
2313
+ term_move(row, left)
389
2314
  line = cells.each_with_index.map { |cell, ci|
390
2315
  pad_to_width(cell, col_widths[ci] || 0)
391
2316
  }.join(' | ')
392
2317
  if ri == 0
393
- @terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
2318
+ @terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: body_scale)}#{ANSI[:reset]}"
394
2319
  else
395
- @terminal.write KittyText.sized(line, s: DEFAULT_SCALE)
2320
+ @terminal.write KittyText.sized(line, s: body_scale)
396
2321
  end
397
- row += DEFAULT_SCALE
2322
+ row += body_scale
398
2323
 
399
2324
  if ri == 0
400
- @terminal.move_to(row, left)
401
- @terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: DEFAULT_SCALE)
402
- row += DEFAULT_SCALE
2325
+ term_move(row, left)
2326
+ @terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: body_scale)
2327
+ row += body_scale
403
2328
  end
404
2329
  end
405
2330
  row
406
2331
  end
407
2332
 
2333
+ # Layout/flow dispatch for :image blocks. Fully-positioned images
2334
+ # (both x and y set) were already rendered in the slide's pre-pass
2335
+ # — skip them here so we don't re-emit the placement (which would
2336
+ # re-erase the cells that subsequent text writes have since
2337
+ # restored).
2338
+ def render_image_or_skip(block, width, row)
2339
+ attrs = effective_attrs(block)
2340
+ return row if attrs['x'] && attrs['y']
2341
+ render_image(block, width, row)
2342
+ end
2343
+
408
2344
  def render_image(block, width, row)
409
2345
  path = resolve_image_path(block[:path])
410
- return row + DEFAULT_SCALE unless File.exist?(path)
2346
+ return row + body_scale unless File.exist?(path)
411
2347
 
412
2348
  img_size = ImageUtil.image_size(path)
413
- return row + DEFAULT_SCALE unless img_size
2349
+ return row + body_scale unless img_size
414
2350
 
415
2351
  img_w, img_h = img_size
416
2352
  cell_w, cell_h = @terminal.cell_pixel_size
417
2353
 
418
- available_rows = @terminal.height - row - 2
419
- left = content_left(width)
420
- available_cols = width - left * 2
421
-
422
- # Cap the default vertical area to 70 % of the screen, matching what
423
- # `{:relative_height="70"}` would do explicitly. Large images that
424
- # extend to within a couple of rows of the screen edge render
425
- # unreliably in some terminals — they're known-good at 70 %, and
426
- # smaller images sit well within this cap so they're unaffected.
427
- # An explicit `relative_height` still overrides.
428
- default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
429
- rh = block[:attrs]['relative_height'] || default_rh
430
- target_rows = (@terminal.height * rh.to_i / 100.0).to_i
431
- available_rows = [target_rows, available_rows].min
432
-
433
- # Calculate target cell size maintaining aspect ratio
2354
+ attrs = effective_attrs(block)
2355
+ # Detect `x="center"` / `"right"` (and y twins) up front. The
2356
+ # cell value can't be computed yet — it depends on the image's
2357
+ # rendered cell footprint, which isn't known until target_cols
2358
+ # / target_rows are resolved below. We just remember the
2359
+ # keyword and circle back. Anything that isn't a keyword falls
2360
+ # through to resolve_at_coord's numeric / `%` / `px` / `c` path
2361
+ # — sub-cell px offsets included.
2362
+ x_keyword = alignment_keyword(attrs['x'], :x)
2363
+ y_keyword = alignment_keyword(attrs['y'], :y)
2364
+ # `<img>` x/y resolve with sub-cell pixel precision: each axis
2365
+ # comes back as `[anchor_cell, px_offset_within_anchor]`. The
2366
+ # anchor cell handles where to move the cursor for layering /
2367
+ # fallback paths; the px offset feeds Kitty Graphics' `X=`/`Y=`
2368
+ # so a "100px from the left" request lands at 100 px, not at
2369
+ # the nearest cell edge.
2370
+ abs_x_pair = resolve_at_coord(attrs['x'], @terminal.width, cell_px: cell_w, default_unit: :px, with_offset: true) unless x_keyword
2371
+ abs_y_pair = resolve_at_coord(attrs['y'], @terminal.height, cell_px: cell_h, default_unit: :px, with_offset: true) unless y_keyword
2372
+ abs_x, abs_x_off = abs_x_pair if abs_x_pair
2373
+ abs_y, abs_y_off = abs_y_pair if abs_y_pair
2374
+ # `x` or `y` (either / both) pins the image at that absolute cell;
2375
+ # the unspecified axis falls back to the flow default (centered
2376
+ # in `width` for x, the current `row` for y). Any explicit
2377
+ # positioning makes the image contribute 0 to the layout flow
2378
+ # — same as `<at>` — so a single-axis pin doesn't push the next
2379
+ # block past where the image landed.
2380
+ positioned = !abs_x.nil? || !abs_y.nil? || !x_keyword.nil? || !y_keyword.nil?
2381
+
2382
+ # Compute the image's intrinsic size in cells. Default is to draw
2383
+ # at intrinsic size — no auto-fit shrinking. If the image is
2384
+ # larger than the available pane, the terminal clips the overflow
2385
+ # (same as a tall string of text scrolling off the bottom).
2386
+ #
2387
+ # `relative_height="N"` / `relative_width="N"` (set directly, or
2388
+ # via the `height="N%"` / `width="N%"` aliases) are explicit
2389
+ # author caps: they shrink the image proportionally so that no
2390
+ # axis exceeds N % of the terminal. They never grow it.
434
2391
  img_cell_w = img_w.to_f / cell_w
435
2392
  img_cell_h = img_h.to_f / cell_h
436
- scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
437
- target_cols = (img_cell_w * scale).to_i
438
- target_rows = (img_cell_h * scale).to_i
439
- target_cols = [target_cols, 1].max
440
- target_rows = [target_rows, 1].max
441
-
442
- x = [(width - target_cols) / 2, 0].max
2393
+ scale = 1.0
2394
+
2395
+ # Pixel sizing — `height="200"` / `width="300"` (plain integer,
2396
+ # optional `px` suffix) force-resizes the image to that pixel
2397
+ # target. Unlike `relative_*` caps, a pixel value can scale the
2398
+ # image UP as well as down — they're an exact "render at this
2399
+ # size" request, not a cap. Setting both shrinks to fit inside
2400
+ # the smaller of the two ratios (aspect ratio preserved).
2401
+ px_scale = nil
2402
+ if (h_px = parse_px_size(attrs['height']))
2403
+ px_scale = h_px / img_h.to_f
2404
+ end
2405
+ if (w_px = parse_px_size(attrs['width']))
2406
+ w_scale = w_px / img_w.to_f
2407
+ px_scale = px_scale ? [px_scale, w_scale].min : w_scale
2408
+ end
2409
+ scale = px_scale if px_scale
2410
+
2411
+ # `relative_height="N"` / `relative_width="N"` (set directly, or
2412
+ # via the `height="N%"` / `width="N%"` aliases) are explicit
2413
+ # author caps: they shrink the image proportionally so that no
2414
+ # axis exceeds N % of the terminal. They never grow it, and they
2415
+ # win over a px sizing request when smaller.
2416
+ if (rh = attrs['relative_height']) && rh.to_i.positive?
2417
+ max_cell_h = @terminal.height * rh.to_i / 100.0
2418
+ scale = [scale, max_cell_h / img_cell_h].min
2419
+ end
2420
+ if (rw = attrs['relative_width']) && rw.to_i.positive?
2421
+ max_cell_w = @terminal.width * rw.to_i / 100.0
2422
+ scale = [scale, max_cell_w / img_cell_w].min
2423
+ end
2424
+ target_cols = [(img_cell_w * scale).to_i, 1].max
2425
+ target_rows = [(img_cell_h * scale).to_i, 1].max
2426
+
2427
+ # Now that the rendered cell footprint is known, resolve any
2428
+ # alignment keywords. `right` / `bottom` anchor flush to the
2429
+ # pane edge; `center` / `middle` centre against target_cols /
2430
+ # target_rows; `left` / `top` snap to col 1 / row 1. Sub-cell
2431
+ # px offsets are zeroed because keywords are cell-grid choices.
2432
+ if x_keyword
2433
+ abs_x = alignment_keyword_cell(x_keyword, @terminal.width, target_cols, :x)
2434
+ abs_x_off = 0
2435
+ end
2436
+ if y_keyword
2437
+ abs_y = alignment_keyword_cell(y_keyword, @terminal.height, target_rows, :y)
2438
+ abs_y_off = 0
2439
+ end
443
2440
 
444
- if ImageUtil.kitty_terminal? && ImageUtil.png?(path)
2441
+ # Per-axis position: explicit `x` / `y` win; absent axes pick
2442
+ # up the flow default. `x_cell` folds the active slot offset
2443
+ # into the centered flow position so both move_to and
2444
+ # kitty_icat land in the right column when rendering inside a
2445
+ # layout slot. The absolute coords already name the screen
2446
+ # cell directly.
2447
+ y_cell = abs_y || row
2448
+ x_cell = abs_x || ([(width - target_cols) / 2, 0].max + 1 + @x_offset)
2449
+ # Sub-cell pixel offsets only flow through when that axis was
2450
+ # explicitly positioned in pixel-form. Flow / centered / cell-
2451
+ # form values land on cell boundaries and pass 0.
2452
+ x_off = abs_x_off || 0
2453
+ y_off = abs_y_off || 0
2454
+
2455
+ # Direct kitty-graphics upload for PNGs always; for non-PNG
2456
+ # raster formats only on Echoes — its NSBitmapImageRep-based
2457
+ # decoder treats `f=100` permissively (PNG / JPEG / TIFF / GIF /
2458
+ # BMP all decode through the same code path), so we can ship a
2459
+ # JPG via the same transmit + place pair the PNG path uses and
2460
+ # avoid the `kitten icat` subprocess. Stock kitty rejects non-
2461
+ # PNG bytes at `f=100` (EBADDATA), so we keep the icat fallback
2462
+ # for it. icat doesn't cope with placements that exceed the
2463
+ # visible cells, so the PNG path is also where intrinsic-size
2464
+ # oversize images work cleanly.
2465
+ if ImageUtil.kitty_terminal? && (ImageUtil.png?(path) || KittyText.echoes?)
445
2466
  image_id = ensure_kitty_uploaded(path)
446
- @terminal.move_to(row, x + 1)
447
- @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
2467
+ @terminal.move_to(y_cell, x_cell)
2468
+ # z layering: explicit `z="..."` attr wins. With no z attr, an
2469
+ # image rendered in slide flow stays on top of cells (default
2470
+ # Kitty z=0 behavior) — that's what authors want for the
2471
+ # common single-image-per-slide case. A pinned image (both x
2472
+ # and y given) is more likely to be sitting under other slide
2473
+ # content, so it defaults to z=-1 (behind text) — same trick
2474
+ # `<bg image="..."/>` uses. Authors can override with z="0"
2475
+ # / z="1" / etc. when they want the image on top of text.
2476
+ z = if attrs['z']
2477
+ attrs['z'].to_i
2478
+ elsif abs_x && abs_y
2479
+ -1
2480
+ else
2481
+ nil
2482
+ end
2483
+ @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows, z: z, x_off: x_off, y_off: y_off)
448
2484
  elsif ImageUtil.kitty_terminal?
449
- data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
2485
+ data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x_cell - 1, y: y_cell - 1)
450
2486
  @terminal.write data if data && !data.empty?
451
2487
  elsif ImageUtil.sixel_available?
452
- @terminal.move_to(row, x + 1)
2488
+ @terminal.move_to(y_cell, x_cell)
453
2489
  target_pixel_w = target_cols * cell_w
454
2490
  target_pixel_h = target_rows * cell_h
455
2491
  sixel = cached_sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
456
2492
  @terminal.write sixel if sixel && !sixel.empty?
457
2493
  end
458
2494
 
459
- row + target_rows
2495
+ positioned ? row : row + target_rows
2496
+ end
2497
+
2498
+ # Parse an `<img>` height / width attribute as a pixel target.
2499
+ # Accepts `"200"` and `"200px"` (CSS-style suffix, forgiving). A
2500
+ # bare integer with no unit is interpreted as pixels. `nil` /
2501
+ # empty / a percent value / anything non-numeric returns nil so
2502
+ # the caller falls back to relative or intrinsic sizing.
2503
+ def parse_px_size(raw)
2504
+ return nil unless raw.is_a?(String)
2505
+ m = raw.match(/\A(\d+)(?:px)?\z/)
2506
+ m ? m[1].to_i : nil
460
2507
  end
461
2508
 
462
2509
  def resolve_image_path(path)
@@ -497,6 +2544,20 @@ module Przn
497
2544
  @kitty_uploads[key] = image_id
498
2545
  end
499
2546
 
2547
+ # Upload any inline image bytes (currently used by shape SVGs) once
2548
+ # via Kitty graphics direct-data transmission. Keyed by SHA1 so
2549
+ # identical shapes on multiple slides dedup. Shares the
2550
+ # @kitty_uploads cache (and id counter) with PNG / file uploads.
2551
+ def ensure_kitty_inline_uploaded(bytes)
2552
+ require 'digest'
2553
+ key = [:inline, Digest::SHA1.hexdigest(bytes.to_s)]
2554
+ return @kitty_uploads[key] if @kitty_uploads.key?(key)
2555
+
2556
+ image_id = @kitty_uploads.size + 1
2557
+ @terminal.write ImageUtil.kitty_upload_inline(bytes, image_id: image_id)
2558
+ @kitty_uploads[key] = image_id
2559
+ end
2560
+
500
2561
  def content_left(width)
501
2562
  width / 16
502
2563
  end
@@ -513,6 +2574,49 @@ module Przn
513
2574
  end
514
2575
  end
515
2576
 
2577
+ # Can this line use the server-side centering shortcut? Four
2578
+ # conditions must hold:
2579
+ # - The line is centered (align == :center). The shortcut is a
2580
+ # centering tool; left- and right-aligned lines keep the
2581
+ # existing pad-based path.
2582
+ # - A proportional `face` is set. Monospace lines center fine
2583
+ # under the cell-grid estimate; the shortcut only matters
2584
+ # when the font's actual rendered width disagrees with cell
2585
+ # count, which is the proportional case.
2586
+ # - We're running in Echoes. The shortcut relies on Echoes'
2587
+ # widened OSC 7772 `w=` parameter and its pixel-precise
2588
+ # halign engine. Strict-kitty terminals cap w at 7 (insufficient
2589
+ # for a slot-spanning block) and don't measure proportional
2590
+ # glyphs anyway, so the fallback path is the right answer there.
2591
+ # - The line is a single :text segment. Multi-segment lines
2592
+ # (a `<color=red>...` halfway through, say) need per-segment
2593
+ # SGR/face changes that one multicell can't express; they
2594
+ # keep the per-segment path and accept the cell-grid drift.
2595
+ #
2596
+ # `width / scale >= 1` is also required so the OSC 7772 `w=`
2597
+ # field has a positive integer to take.
2598
+ def server_centered_eligible?(line_segs, face, align, width, scale)
2599
+ align == :center &&
2600
+ face && !face.to_s.empty? &&
2601
+ KittyText.echoes? &&
2602
+ line_segs.size == 1 && line_segs.first[0] == :text &&
2603
+ scale && scale > 0 && (width.to_f / scale).floor >= 1
2604
+ end
2605
+
2606
+ # Compute the slot-relative cursor column + the multicell body
2607
+ # bytes for a server-side-centered line. The block is
2608
+ # `floor(width/scale) * scale` cells wide so it fits inside the
2609
+ # slot; any leftover slot space splits evenly via `lead_cells`
2610
+ # so the BLOCK is slot-centered. Halign=2 then centers the text
2611
+ # pixel-precisely inside the block.
2612
+ def server_centered_emit(text, scale, face, width)
2613
+ block_w_units = (width.to_f / scale).floor
2614
+ block_w = block_w_units * scale
2615
+ lead_cells = (width - block_w) / 2
2616
+ body = KittyText.sized(text, s: scale, w: block_w_units, h: 2, f: face)
2617
+ [lead_cells + 1, body]
2618
+ end
2619
+
516
2620
  def render_inline(text)
517
2621
  Parser.parse_inline(text).map { |segment|
518
2622
  type = segment[0]
@@ -546,25 +2650,69 @@ module Przn
546
2650
  # the row, which looks wrong against the larger body text.
547
2651
  def render_bullet(prefix)
548
2652
  size = @theme.bullet[:size]
549
- if size && size < DEFAULT_SCALE
550
- KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
551
- else
552
- KittyText.sized(prefix, s: size || DEFAULT_SCALE)
553
- end
2653
+ sized =
2654
+ if size && size < body_scale
2655
+ KittyText.sized(prefix, s: body_scale, n: size, d: body_scale, v: 2)
2656
+ else
2657
+ KittyText.sized(prefix, s: size || body_scale)
2658
+ end
2659
+ color = @theme.bullet[:color]
2660
+ return sized unless color && !color.to_s.empty?
2661
+ "#{color_code(color.to_s)}#{sized}#{ANSI[:reset]}"
554
2662
  end
555
2663
 
556
2664
  # Render a <font face="..." size="..." color="..."> run. The face goes out
557
2665
  # via OSC 66 f= (Echoes extension); the size resolves through the same
558
2666
  # SIZE_SCALES table that <size=N> uses; the color wraps in the same ANSI
559
- # escape that <color=NAME> uses.
560
- def render_font_segment(content, attrs, para_scale, default_face: nil, default_h: nil)
2667
+ # escape that <color=NAME> uses. `line_height:` lets a small font ride
2668
+ # at the bottom of a taller line (see `sized_in_line`).
2669
+ def render_font_segment(content, attrs, para_scale, line_height: nil, mixed_line: false, default_face: nil, default_h: nil)
561
2670
  scale = (attrs[:size] && Parser::SIZE_SCALES[attrs[:size]]) || para_scale
562
- base = KittyText.sized(content, s: scale, f: attrs[:face] || default_face, h: default_h)
2671
+ base = sized_in_line(content, seg_scale: scale, line_height: line_height, mixed_line: mixed_line, f: attrs[:face] || default_face, h: default_h)
563
2672
  color = attrs[:color]
564
2673
  return base unless color
565
2674
  "#{color_code(color)}#{base}#{ANSI[:reset]}"
566
2675
  end
567
2676
 
2677
+ # Wrap `content` in OSC 66 so segments on a mixed-size line share a
2678
+ # typographic baseline (not just a block-bottom edge). All segments
2679
+ # emit `s=line_height` so their multicell blocks have the same top
2680
+ # and bottom; then `v=3` (Echoes-private baseline-align mode) tells
2681
+ # the renderer to position each one so its baseline lands at the
2682
+ # same y within the block, regardless of font/scale.
2683
+ #
2684
+ # - Shorter than line_height → s=line_height,n=seg,d=line_height,v=3.
2685
+ # Block matches the tallest sibling; glyph drawn at seg/line_height
2686
+ # ratio; baseline shared with the tall siblings.
2687
+ #
2688
+ # - Equal to line_height but on a mixed line → s=seg_scale,v=3.
2689
+ # Same block size as the shorter siblings (since they all emit
2690
+ # s=line_height too). v=3 keeps everyone on the same baseline.
2691
+ #
2692
+ # - Anything when line_height is nil OR no mixed-size line at all →
2693
+ # plain `s=seg_scale`. Byte-identical to the pre-line-height path
2694
+ # so single-scale paragraphs / lists / etc. don't drift.
2695
+ #
2696
+ # `mixed_line:` tells the helper whether *some other* segment on this
2697
+ # line is shorter than line_height — the caller computes it once per
2698
+ # line because the helper itself only sees one segment at a time.
2699
+ #
2700
+ # Echoes' v= mapping (echoes/lib/echoes/gui.rb:1269):
2701
+ # v=0 (default) → top
2702
+ # v=1 → text-bottom at block-bottom
2703
+ # v=2 → text-centered in block
2704
+ # v=3 → baseline at `block_bottom - cell_h + default_ascender`
2705
+ # (Echoes-private; falls back to top on strict kitty)
2706
+ def sized_in_line(content, seg_scale:, line_height:, mixed_line: false, f: nil, h: nil)
2707
+ if line_height && line_height > seg_scale
2708
+ KittyText.sized(content, s: line_height, n: seg_scale, d: line_height, v: 3, f: f, h: h)
2709
+ elsif mixed_line
2710
+ KittyText.sized(content, s: seg_scale, v: 3, f: f, h: h)
2711
+ else
2712
+ KittyText.sized(content, s: seg_scale, f: f, h: h)
2713
+ end
2714
+ end
2715
+
568
2716
  # `default_face:` / `default_color:` let a caller (currently h1 rendering)
569
2717
  # override the OSC 66 `f=` and the ANSI fg for every emit on the line.
570
2718
  # When unset, body text falls back to `theme.font.family` / `theme.font.color`.
@@ -578,11 +2726,20 @@ module Przn
578
2726
  # centered within the reserved cell block — without it the glyphs left-
579
2727
  # align inside the block and the visible text drifts left of the center
580
2728
  # column we computed.
581
- def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil, default_color: :body)
582
- f = default_face == :body ? @theme.font[:family] : default_face
2729
+ def render_segments_scaled(segments, para_scale, line_height: nil, default_face: :body, default_h: nil, default_color: :body)
2730
+ f = default_face == :body ? (slot_family || @theme.font[:family]) : default_face
583
2731
  h = default_h
584
- c = default_color == :body ? @theme.font[:color] : default_color
2732
+ c = default_color == :body ? (slot_color || @theme.font[:color]) : default_color
585
2733
  body_open = c ? color_code(c) : ''
2734
+ # "Mixed line" = line_height is set AND at least one segment is
2735
+ # shorter than line_height. When true, the tall segment(s) also
2736
+ # get v=1 so they share the baseline with the shorter siblings
2737
+ # (otherwise tall floats to the top and short to the bottom).
2738
+ mixed = line_height &&
2739
+ segments.any? { |s| s[0] != :break && effective_seg_scale(s, para_scale) < line_height }
2740
+ # When `line_height` isn't passed, callers want the pre-mixed-size
2741
+ # behavior — sized_in_line collapses to plain `s=seg_scale`, so
2742
+ # existing wire-shape tests keep their byte-for-byte output.
586
2743
  inner = segments.map { |segment|
587
2744
  type = segment[0]
588
2745
  content = segment[1]
@@ -590,19 +2747,23 @@ module Przn
590
2747
  when :tag
591
2748
  tag_name = segment[2]
592
2749
  if (scale = Parser::SIZE_SCALES[tag_name])
593
- KittyText.sized(content, s: scale, f: f, h: h)
594
- elsif Parser::NAMED_COLORS.key?(tag_name)
595
- "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2750
+ sized_in_line(content, seg_scale: scale, line_height: line_height, mixed_line: mixed, f: f, h: h)
2751
+ elsif !(code = color_code(tag_name)).empty?
2752
+ # `<color=NAME>`, `<color=ff5555>`, `<color=#ff5555>`, the
2753
+ # kramdown `{::tag name="red"}` form — anything color_code
2754
+ # can resolve gets wrapped in that SGR. Same engine
2755
+ # `<font color="...">` uses, so the two stay in sync.
2756
+ "#{code}#{sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
596
2757
  else
597
- KittyText.sized(content, s: para_scale, f: f, h: h)
2758
+ sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)
598
2759
  end
599
- when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
600
- when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
601
- when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
602
- when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
603
- when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
604
- when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
605
- when :text then KittyText.sized(content, s: para_scale, f: f, h: h)
2760
+ when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, line_height: line_height, mixed_line: mixed, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
2761
+ when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2762
+ when :bold then "#{ANSI[:bold]}#{sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2763
+ when :italic then "#{ANSI[:italic]}#{sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2764
+ when :strikethrough then "#{ANSI[:strikethrough]}#{sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2765
+ when :code then "#{ANSI[:gray_bg]}#{sized_in_line(" #{content} ", seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
2766
+ when :text then sized_in_line(content, seg_scale: para_scale, line_height: line_height, mixed_line: mixed, f: f, h: h)
606
2767
  end
607
2768
  }.join
608
2769
  body_open.empty? ? inner : "#{body_open}#{inner}#{ANSI[:reset]}"
@@ -615,7 +2776,20 @@ module Przn
615
2776
  # Wrap parsed inline segments into lines that fit within max_width units,
616
2777
  # where 1 unit = `para_scale` terminal cells. Per-segment scaling (e.g.
617
2778
  # size tags) is honored so a span with a larger scale consumes more budget.
618
- def wrap_segments(segments, max_width, para_scale = DEFAULT_SCALE)
2779
+ def wrap_segments(segments, max_width, para_scale = body_scale)
2780
+ # Force-break support: split at `:break` segments (from <br>),
2781
+ # wrap each chunk independently against `max_width`, then
2782
+ # concatenate the resulting lines. An empty chunk (two `<br>`s
2783
+ # in a row, or a leading / trailing `<br>`) yields one blank
2784
+ # line, matching HTML semantics.
2785
+ if segments.any? { |s| s[0] == :break }
2786
+ groups = [[]]
2787
+ segments.each do |seg|
2788
+ seg[0] == :break ? (groups << []) : (groups.last << seg)
2789
+ end
2790
+ return groups.flat_map { |g| wrap_segments(g, max_width, para_scale) }
2791
+ end
2792
+
619
2793
  return [segments] if max_width <= 0
620
2794
 
621
2795
  max_cells = max_width * para_scale
@@ -717,44 +2891,66 @@ module Przn
717
2891
  text + ' ' * [target_width - current, 0].max
718
2892
  end
719
2893
 
2894
+ # Largest OSC 66 scale used inside the paragraph's inline markup,
2895
+ # used by callers to decide how many rows to advance the layout
2896
+ # row by per text line. Recognizes all three size-bearing
2897
+ # spellings: the kramdown `{::tag name="..."}`, the XML
2898
+ # `<size=N>`, and `<font size="..."/>` — each emits OSC 66 text
2899
+ # that occupies `N` cells tall, so a list item or paragraph
2900
+ # carrying any of them needs the row to advance by N rather
2901
+ # than the body scale.
720
2902
  def max_inline_scale(text)
721
2903
  max = 0
722
- text.scan(/\{::tag\s+name="([^"]+)"\}/) do
723
- scale = Parser::SIZE_SCALES[$1]
724
- max = scale if scale && scale > max
2904
+ [
2905
+ /\{::tag\s+name="([^"]+)"\}/,
2906
+ /<size=(?:"([^"]*)"|'([^']*)'|([^>\s]+))>/,
2907
+ /<font\b[^>]*\bsize=["']?([^"'\s>]+)/
2908
+ ].each do |re|
2909
+ text.scan(re) do |groups|
2910
+ value = Array(groups).compact.first
2911
+ scale = Parser::SIZE_SCALES[value]
2912
+ max = scale if scale && scale > max
2913
+ end
725
2914
  end
726
2915
  max > 0 ? max : nil
727
2916
  end
728
2917
 
729
2918
  def visible_width_scaled(text, default_scale)
730
2919
  Parser.parse_inline(text).sum { |segment|
731
- type = segment[0]
732
- content = segment[1]
733
- case type
734
- when :tag
735
- scale = Parser::SIZE_SCALES[segment[2]] || default_scale
736
- display_width(content) * scale
737
- else
738
- display_width(content) * default_scale
739
- end
2920
+ content = segment[1] || ''
2921
+ display_width(content) * effective_seg_scale(segment, default_scale)
740
2922
  }
741
2923
  end
742
2924
 
2925
+ # Resolve a CSS-ish color value into an opening ANSI SGR escape.
2926
+ # Accepts: a named ANSI color (`red`, `bright_cyan`, …), a 6-digit
2927
+ # hex code (`ff5555`, with or without a leading `#`). Returns `''`
2928
+ # for an unrecognized value so callers can no-op safely.
2929
+ #
2930
+ # Named colors prefer the CSS table over the ANSI palette so that
2931
+ # `<color=red>` (font) and `<rect stroke="red"/>` (shape) render
2932
+ # the same `#ff0000` on screen — they used to drift because shape
2933
+ # paint went through `normalize_svg_color` (CSS hex) while text
2934
+ # went through Parser::NAMED_COLORS (terminal-theme ANSI). The
2935
+ # 147-name CSS table covers every basic colour name plus dozens
2936
+ # of CSS-only names like tomato / lavender that previously
2937
+ # rendered as no-op on fonts. `bright_*` and any other name not
2938
+ # in CSS still falls through to the ANSI palette.
743
2939
  def color_code(color)
744
- if (code = Parser::NAMED_COLORS[color])
2940
+ c = color.to_s.sub(/\A#/, '')
2941
+ if (hex = CSS_NAMED_COLORS[c.downcase])
2942
+ r, g, b = hex.scan(/../).map { |h| h.to_i(16) }
2943
+ "\e[38;2;#{r};#{g};#{b}m"
2944
+ elsif (code = Parser::NAMED_COLORS[c])
745
2945
  "\e[#{code}m"
746
- elsif color.match?(/\A[0-9a-fA-F]{6}\z/)
747
- r, g, b = color.scan(/../).map { |h| h.to_i(16) }
2946
+ elsif c.match?(/\A[0-9a-fA-F]{6}\z/)
2947
+ r, g, b = c.scan(/../).map { |h| h.to_i(16) }
748
2948
  "\e[38;2;#{r};#{g};#{b}m"
749
2949
  else
750
2950
  ''
751
2951
  end
752
2952
  end
753
2953
 
754
- def visible_length(text)
755
- display_width(strip_markup(text))
756
- end
757
-
758
2954
  def display_width(str)
759
2955
  str.each_char.sum { |c|
760
2956
  o = c.ord
@@ -780,95 +2976,5 @@ module Przn
780
2976
  }
781
2977
  end
782
2978
 
783
- def strip_markup(text)
784
- text
785
- .gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
786
- .gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
787
- .gsub('{::wait/}', '')
788
- .gsub(/\*\*(.+?)\*\*/, '\1')
789
- .gsub(/\*(.+?)\*/, '\1')
790
- .gsub(/~~(.+?)~~/, '\1')
791
- .gsub(/`([^`]+)`/, '\1')
792
- .gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
793
- end
794
-
795
- def calculate_height(blocks, width)
796
- blocks.sum { |b| block_height(b, width) }
797
- end
798
-
799
- def block_height(block, width)
800
- s = DEFAULT_SCALE
801
- left = content_left(width)
802
- max_w = max_text_width(width, left, s)
803
-
804
- case block[:type]
805
- when :heading
806
- scale = KittyText::HEADING_SCALES[block[:level]] || s
807
- if block[:level] == 1
808
- scale + 4
809
- else
810
- lines_count(block[:content], [max_w - 2, 1].max) * scale
811
- end
812
- when :paragraph
813
- para_scale = max_inline_scale(block[:content]) || s
814
- lines_count(block[:content], [max_text_width(width, left, para_scale), 1].max) * para_scale
815
- when :code_block
816
- [block[:content].lines.size * s, s].max
817
- when :unordered_list
818
- block[:items].sum { |item|
819
- prefix_w = (item[:depth] || 0) * 2 + 2
820
- lines_count(item[:text], [max_w - prefix_w, 1].max) * s
821
- }
822
- when :ordered_list
823
- block[:items].size * s
824
- when :definition_list
825
- term_lines = lines_count(block[:term], [max_w, 1].max)
826
- def_lines = block[:definition].lines.sum { |l| lines_count(l.chomp, [max_w - 4, 1].max) }
827
- (term_lines + def_lines) * s
828
- when :blockquote
829
- block[:content].lines.sum { |l| lines_count(l.chomp, [max_w - 3, 1].max) } * s
830
- when :table
831
- ((block[:header] ? 2 : 0) + block[:rows].size) * s
832
- when :image
833
- image_block_height(block, width)
834
- when :align
835
- 0
836
- when :bg
837
- 0
838
- when :blank
839
- s
840
- else
841
- s
842
- end
843
- end
844
-
845
- def lines_count(text, max_width)
846
- vis_w = display_width(strip_markup(text))
847
- return 1 if vis_w <= max_width
848
- (vis_w.to_f / max_width).ceil
849
- end
850
-
851
- def image_block_height(block, width)
852
- path = resolve_image_path(block[:path])
853
- img_size = ImageUtil.image_size(path)
854
- return DEFAULT_SCALE unless img_size
855
-
856
- img_w, img_h = img_size
857
- cell_w, cell_h = @terminal.cell_pixel_size
858
- h = @terminal.height
859
-
860
- left = content_left(width)
861
- available_cols = width - left * 2
862
- available_rows = h / 2
863
-
864
- if (rh = block[:attrs]['relative_height'])
865
- available_rows = (h * rh.to_i / 100.0).to_i
866
- end
867
-
868
- img_cell_w = img_w.to_f / cell_w
869
- img_cell_h = img_h.to_f / cell_h
870
- scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
871
- [(img_cell_h * scale).ceil, 1].max
872
- end
873
2979
  end
874
2980
  end