przn 0.5.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
72
168
  end
73
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
182
+ end
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,23 +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
- when :at then render_at(block); row
113
- 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"
114
1022
  end
115
1023
  end
116
1024
 
@@ -123,8 +1031,17 @@ module Przn
123
1031
  attrs = block ? block[:attrs] : (@theme.background || {})
124
1032
 
125
1033
  @terminal.write "\e]7772;bg-clear\a"
1034
+ clear_background_image
126
1035
  return if attrs.empty?
127
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
+
128
1045
  if (color = attrs[:color])
129
1046
  @terminal.write "\e]7772;bg-color;#{color}\a"
130
1047
  return
@@ -138,6 +1055,37 @@ module Przn
138
1055
  @terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
139
1056
  end
140
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
+
141
1089
  # Place text at an absolute (column, row) on the slide, escaping the
142
1090
  # normal top-down paragraph flow. Coordinates are 1-based terminal cells
143
1091
  # to match the CSI cursor-position escape. A trailing `%` interprets the
@@ -148,36 +1096,730 @@ module Przn
148
1096
  # The block contributes 0 to the slide's layout height so it doesn't
149
1097
  # push subsequent content down.
150
1098
  def render_at(block)
151
- attrs = block[:attrs] || {}
152
- x = resolve_at_coord(attrs[:x], @terminal.width)
153
- y = resolve_at_coord(attrs[:y], @terminal.height)
154
- return if x.nil? || y.nil?
1099
+ attrs = effective_attrs(block)
1100
+ cell_w, cell_h = @terminal.cell_pixel_size
155
1101
 
156
1102
  segments = Parser.parse_inline(block[:content].to_s)
157
- @terminal.move_to(y, x)
158
- @terminal.write render_segments_scaled(segments, DEFAULT_SCALE)
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
159
1135
  end
160
1136
 
161
- # Resolve an `<at>` coordinate string against the dimension it indexes.
162
- # `"50%"` → halfway along `max`; plain integer string → that number of
163
- # cells. Out-of-range values clamp into [1, max]. Returns nil when the
164
- # input is missing or unparseable so the renderer skips silently.
165
- def resolve_at_coord(raw, max)
166
- return nil if raw.nil?
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
167
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?
168
1370
  s = raw.to_s.strip
169
1371
  return nil if s.empty?
170
1372
 
1373
+ cell_dim = (axis == :x) ? cell_w : cell_h
1374
+ total_cells = (axis == :x) ? @terminal.width : @terminal.height
1375
+
171
1376
  cells =
172
1377
  if s.end_with?('%')
173
- pct = s.chomp('%').to_f
174
- (pct / 100.0 * max).round
175
- elsif s =~ /\A-?\d+\z/
176
- s.to_i
1378
+ s.chomp('%').to_f / 100.0 * total_cells
1379
+ elsif s =~ /\A-?[\d.]+\z/
1380
+ s.to_f
177
1381
  end
178
1382
  return nil if cells.nil?
179
1383
 
180
- cells.clamp(1, max)
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
181
1823
  end
182
1824
 
183
1825
  # Bottom-row progress indicator (Rabbit-style):
@@ -186,33 +1828,43 @@ module Przn
186
1828
  # └ current slide # └ elapsed time └ slide progress └ goal (total slides)
187
1829
  #
188
1830
  # The anchor numbers (current at the left, total at the right) sit on the
189
- # very bottom row; the emojis render at OSC 66 scale 2 and are anchored at
190
- # row `h-1` so their bottom half lands on row `h` next to the numbers,
191
- # making them visibly twice as large as the labels without needing more
192
- # vertical screen real-estate. The turtle is hidden when
193
- # `theme.rabbit.duration` is unset / unparseable. `flip=h` mirrors each
194
- # glyph horizontally on terminals that honor it (Echoes); others ignore
195
- # the parameter and the emojis face left.
196
- 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
197
1838
 
198
1839
  def draw_runner_bar(h, w, current, total, started_at)
199
1840
  left = (current + 1).to_s
200
1841
  right = total.to_s
201
- track_left = left.size + 2 # 1 cell gap after the left number
202
- track_right = w - right.size - 1 # 1 cell gap before the right number
203
- 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)
204
1855
 
1856
+ open_seq = counter_color_open
205
1857
  @terminal.move_to(h, 1)
206
- @terminal.write "#{ANSI[:dim]}#{left}#{ANSI[:reset]}"
1858
+ @terminal.write "#{open_seq}#{left}#{ANSI[:reset]}"
207
1859
  @terminal.move_to(h, w - right.size + 1)
208
- @terminal.write "#{ANSI[:dim]}#{right}#{ANSI[:reset]}"
1860
+ @terminal.write "#{open_seq}#{right}#{ANSI[:reset]}"
209
1861
 
210
- rabbit_row = [h - 1, 1].max
1862
+ counter = @theme&.counter || {}
211
1863
  rabbit_col = runner_col(current, [total - 1, 1].max, track_left, track_right)
212
1864
  @terminal.move_to(rabbit_row, rabbit_col)
213
- @terminal.write KittyText.sized('🐇', s: 2, flip: 'h')
1865
+ @terminal.write counter[:rabbit].to_s
214
1866
 
215
- duration_s = Theme.parse_duration(@theme.rabbit[:duration])
1867
+ duration_s = counter_duration
216
1868
  return unless started_at && duration_s && duration_s.positive?
217
1869
 
218
1870
  elapsed = Time.now - started_at
@@ -220,7 +1872,17 @@ module Przn
220
1872
  span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
221
1873
  turtle_col = track_left + (frac * [span, 0].max).round
222
1874
  @terminal.move_to(rabbit_row, turtle_col)
223
- @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]
224
1886
  end
225
1887
 
226
1888
  # Linear-interpolate a runner's column inside the track. `step` is 0..max
@@ -234,42 +1896,54 @@ module Przn
234
1896
  track_left + (step.to_f / max * span).round
235
1897
  end
236
1898
 
237
- def render_heading(block, width, row)
1899
+ def render_heading(block, width, row, align: nil)
238
1900
  text = block[:content]
239
1901
 
240
1902
  if block[:level] == 1
241
1903
  title = @theme.title || {}
242
- scale = (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
243
- face = title[:family]
244
- 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]
245
1907
  max_w = max_text_width(width, 0, scale)
246
1908
  segments = Parser.parse_inline(text)
247
1909
  wrapped = wrap_segments(segments, max_w, scale)
248
1910
 
249
1911
  wrapped.each do |line_segs|
250
- vis = segments_visible_cells(line_segs, scale)
251
- pad = [(width - vis) / 2, 0].max
252
- @terminal.move_to(row, pad + 1)
253
- @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
254
1923
  row += scale
255
1924
  end
256
- 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
257
1931
  else
258
1932
  left = content_left(width)
259
1933
  prefix = @theme.bullet[:text]
260
1934
  prefix_w = display_width(prefix)
261
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
1935
+ max_w = max_text_width(width, left, body_scale) - prefix_w
262
1936
  segments = Parser.parse_inline(text)
263
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
1937
+ wrapped = wrap_segments(segments, max_w, body_scale)
264
1938
 
265
1939
  wrapped.each_with_index do |line_segs, li|
266
- @terminal.move_to(row, left)
1940
+ term_move(row, left)
267
1941
  if li == 0
268
- @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)}"
269
1943
  else
270
- @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)}"
271
1945
  end
272
- row += DEFAULT_SCALE
1946
+ row += body_scale
273
1947
  end
274
1948
  row
275
1949
  end
@@ -277,118 +1951,326 @@ module Przn
277
1951
 
278
1952
  def render_paragraph(block, width, row, align: nil)
279
1953
  text = block[:content]
280
- 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]
281
1965
  left = content_left(width)
282
1966
 
283
1967
  if align
284
- vis = visible_width_scaled(text, scale)
1968
+ vis = visible_width_scaled(text, base_scale)
285
1969
  left = compute_pad(width, vis, align)
286
1970
  end
287
1971
 
288
- max_w = max_text_width(width, left, scale)
1972
+ max_w = max_text_width(width, left, base_scale)
289
1973
  segments = Parser.parse_inline(text)
290
- wrapped = wrap_segments(segments, max_w, scale)
1974
+ wrapped = wrap_segments(segments, max_w, base_scale)
291
1975
 
292
1976
  wrapped.each do |line_segs|
293
- @terminal.move_to(row, left + 1)
294
- @terminal.write render_segments_scaled(line_segs, scale)
295
- 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
296
1996
  end
297
1997
  row
298
1998
  end
299
1999
 
300
2000
  def render_code_block(block, width, row)
301
2001
  code_lines = block[:content].lines.map(&:chomp)
302
- 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)) || ''
303
2022
 
304
2023
  left = content_left(width)
305
- max_content_w = max_text_width(width, left, DEFAULT_SCALE) - 4
2024
+ max_content_w = max_text_width(width, left, scale) - 4
306
2025
  max_len = code_lines.map { |l| display_width(l) }.max
307
2026
  box_content_w = [max_len, max_content_w].min
308
2027
 
309
- code_lines.each do |code_line|
310
- truncated = truncate_to_width(code_line, box_content_w)
311
- padded = pad_to_width(truncated, box_content_w)
312
- @terminal.move_to(row, left + 1)
313
- @terminal.write "#{ANSI[:gray_bg]}#{KittyText.sized(" #{padded} ", s: DEFAULT_SCALE)}#{ANSI[:reset]}"
314
- 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
315
2069
  end
316
2070
 
317
2071
  row
318
2072
  end
319
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
+
320
2159
  def render_unordered_list(block, width, row)
321
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
322
2167
  block[:items].each do |item|
323
- depth = item[:depth] || 0
324
- indent = ' ' * depth
325
- prefix = "#{indent}#{@theme.bullet[:text]}"
326
- prefix_w = display_width(prefix)
327
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
328
-
329
- segments = Parser.parse_inline(item[:text])
330
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
331
-
332
- wrapped.each_with_index do |line_segs, li|
333
- @terminal.move_to(row, left)
334
- if li == 0
335
- @terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
336
- else
337
- @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
338
2193
  end
339
- row += DEFAULT_SCALE
2194
+ row += 1
340
2195
  end
341
- row += 1
342
2196
  end
343
2197
  row
344
2198
  end
345
2199
 
346
2200
  def render_ordered_list(block, width, row)
347
2201
  left = content_left(width)
2202
+ gated = @block_step && @block_step.key?(block)
2203
+ item_step = gated ? @block_step[block] : nil
348
2204
  block[:items].each_with_index do |item, i|
349
- depth = item[:depth] || 0
350
- indent = ' ' * depth
351
- prefix = "#{indent}#{i + 1}. "
352
- prefix_w = display_width(prefix)
353
- max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
354
-
355
- segments = Parser.parse_inline(item[:text])
356
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
357
-
358
- wrapped.each_with_index do |line_segs, li|
359
- @terminal.move_to(row, left)
360
- if li == 0
361
- @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
362
- else
363
- @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
364
2226
  end
365
- row += DEFAULT_SCALE
2227
+ row += 1
366
2228
  end
367
- row += 1
368
2229
  end
369
2230
  row
370
2231
  end
371
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
+
372
2254
  def render_definition_list(block, width, row)
373
2255
  left = content_left(width)
374
- max_w = max_text_width(width, left, DEFAULT_SCALE)
2256
+ max_w = max_text_width(width, left, body_scale)
375
2257
 
376
2258
  segments = Parser.parse_inline(block[:term])
377
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
2259
+ wrapped = wrap_segments(segments, max_w, body_scale)
378
2260
  wrapped.each do |line_segs|
379
- @terminal.move_to(row, left)
380
- @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
381
- 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
382
2264
  end
383
2265
 
384
2266
  def_max_w = [max_w - 4, 1].max
385
2267
  block[:definition].each_line do |line|
386
2268
  segments = Parser.parse_inline(line.chomp)
387
- wrapped = wrap_segments(segments, def_max_w, DEFAULT_SCALE)
2269
+ wrapped = wrap_segments(segments, def_max_w, body_scale)
388
2270
  wrapped.each do |line_segs|
389
- @terminal.move_to(row, left + 4)
390
- @terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
391
- row += DEFAULT_SCALE
2271
+ term_move(row, left + 4)
2272
+ @terminal.write render_segments_scaled(line_segs, body_scale)
2273
+ row += body_scale
392
2274
  end
393
2275
  end
394
2276
  row
@@ -398,18 +2280,18 @@ module Przn
398
2280
  left = content_left(width)
399
2281
  prefix = '| '
400
2282
  prefix_w = display_width(prefix)
401
- 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
402
2284
 
403
2285
  block[:content].each_line do |line|
404
2286
  text = line.chomp
405
2287
  segments = [[:text, text]]
406
- wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
2288
+ wrapped = wrap_segments(segments, max_w, body_scale)
407
2289
 
408
2290
  wrapped.each_with_index do |line_segs, li|
409
- @terminal.move_to(row, left + 1)
2291
+ term_move(row, left + 1)
410
2292
  p = li == 0 ? prefix : ' ' * prefix_w
411
- @terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
412
- 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
413
2295
  end
414
2296
  end
415
2297
  row
@@ -428,81 +2310,177 @@ module Przn
428
2310
  all_rows.each_with_index do |cells, ri|
429
2311
  next unless cells
430
2312
 
431
- @terminal.move_to(row, left)
2313
+ term_move(row, left)
432
2314
  line = cells.each_with_index.map { |cell, ci|
433
2315
  pad_to_width(cell, col_widths[ci] || 0)
434
2316
  }.join(' | ')
435
2317
  if ri == 0
436
- @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]}"
437
2319
  else
438
- @terminal.write KittyText.sized(line, s: DEFAULT_SCALE)
2320
+ @terminal.write KittyText.sized(line, s: body_scale)
439
2321
  end
440
- row += DEFAULT_SCALE
2322
+ row += body_scale
441
2323
 
442
2324
  if ri == 0
443
- @terminal.move_to(row, left)
444
- @terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: DEFAULT_SCALE)
445
- 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
446
2328
  end
447
2329
  end
448
2330
  row
449
2331
  end
450
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
+
451
2344
  def render_image(block, width, row)
452
2345
  path = resolve_image_path(block[:path])
453
- return row + DEFAULT_SCALE unless File.exist?(path)
2346
+ return row + body_scale unless File.exist?(path)
454
2347
 
455
2348
  img_size = ImageUtil.image_size(path)
456
- return row + DEFAULT_SCALE unless img_size
2349
+ return row + body_scale unless img_size
457
2350
 
458
2351
  img_w, img_h = img_size
459
2352
  cell_w, cell_h = @terminal.cell_pixel_size
460
2353
 
461
- attrs = block[:attrs] || {}
462
- abs_x = resolve_at_coord(attrs['x'], @terminal.width)
463
- abs_y = resolve_at_coord(attrs['y'], @terminal.height)
464
- absolute = abs_x && abs_y
465
-
466
- origin_row = absolute ? abs_y : row
467
- available_rows = [@terminal.height - origin_row - 2, 1].max
468
- if absolute
469
- available_cols = [@terminal.width - abs_x + 1, 1].max
470
- else
471
- left = content_left(width)
472
- available_cols = width - left * 2
473
- end
474
-
475
- # Cap the default vertical area to 70 % of the screen, matching what
476
- # `{:relative_height="70"}` would do explicitly. Large images that
477
- # extend to within a couple of rows of the screen edge render
478
- # unreliably in some terminals — they're known-good at 70 %, and
479
- # smaller images sit well within this cap so they're unaffected.
480
- # An explicit `relative_height` still overrides.
481
- default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
482
- rh = attrs['relative_height'] || default_rh
483
- target_rows = (@terminal.height * rh.to_i / 100.0).to_i
484
- available_rows = [target_rows, available_rows].min
485
-
486
- # 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.
487
2391
  img_cell_w = img_w.to_f / cell_w
488
2392
  img_cell_h = img_h.to_f / cell_h
489
- scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
490
- target_cols = (img_cell_w * scale).to_i
491
- target_rows = (img_cell_h * scale).to_i
492
- target_cols = [target_cols, 1].max
493
- target_rows = [target_rows, 1].max
494
-
495
- if absolute
496
- y_cell, x_cell = abs_y, abs_x
497
- else
498
- y_cell = row
499
- x_cell = [(width - target_cols) / 2, 0].max + 1
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
500
2439
  end
501
2440
 
502
- 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?)
503
2466
  image_id = ensure_kitty_uploaded(path)
504
2467
  @terminal.move_to(y_cell, x_cell)
505
- @terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
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)
506
2484
  elsif ImageUtil.kitty_terminal?
507
2485
  data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x_cell - 1, y: y_cell - 1)
508
2486
  @terminal.write data if data && !data.empty?
@@ -514,7 +2492,18 @@ module Przn
514
2492
  @terminal.write sixel if sixel && !sixel.empty?
515
2493
  end
516
2494
 
517
- absolute ? row : 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
518
2507
  end
519
2508
 
520
2509
  def resolve_image_path(path)
@@ -555,6 +2544,20 @@ module Przn
555
2544
  @kitty_uploads[key] = image_id
556
2545
  end
557
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
+
558
2561
  def content_left(width)
559
2562
  width / 16
560
2563
  end
@@ -571,6 +2574,49 @@ module Przn
571
2574
  end
572
2575
  end
573
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
+
574
2620
  def render_inline(text)
575
2621
  Parser.parse_inline(text).map { |segment|
576
2622
  type = segment[0]
@@ -604,25 +2650,69 @@ module Przn
604
2650
  # the row, which looks wrong against the larger body text.
605
2651
  def render_bullet(prefix)
606
2652
  size = @theme.bullet[:size]
607
- if size && size < DEFAULT_SCALE
608
- KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
609
- else
610
- KittyText.sized(prefix, s: size || DEFAULT_SCALE)
611
- 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]}"
612
2662
  end
613
2663
 
614
2664
  # Render a <font face="..." size="..." color="..."> run. The face goes out
615
2665
  # via OSC 66 f= (Echoes extension); the size resolves through the same
616
2666
  # SIZE_SCALES table that <size=N> uses; the color wraps in the same ANSI
617
- # escape that <color=NAME> uses.
618
- 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)
619
2670
  scale = (attrs[:size] && Parser::SIZE_SCALES[attrs[:size]]) || para_scale
620
- 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)
621
2672
  color = attrs[:color]
622
2673
  return base unless color
623
2674
  "#{color_code(color)}#{base}#{ANSI[:reset]}"
624
2675
  end
625
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
+
626
2716
  # `default_face:` / `default_color:` let a caller (currently h1 rendering)
627
2717
  # override the OSC 66 `f=` and the ANSI fg for every emit on the line.
628
2718
  # When unset, body text falls back to `theme.font.family` / `theme.font.color`.
@@ -636,11 +2726,20 @@ module Przn
636
2726
  # centered within the reserved cell block — without it the glyphs left-
637
2727
  # align inside the block and the visible text drifts left of the center
638
2728
  # column we computed.
639
- def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil, default_color: :body)
640
- 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
641
2731
  h = default_h
642
- c = default_color == :body ? @theme.font[:color] : default_color
2732
+ c = default_color == :body ? (slot_color || @theme.font[:color]) : default_color
643
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.
644
2743
  inner = segments.map { |segment|
645
2744
  type = segment[0]
646
2745
  content = segment[1]
@@ -648,19 +2747,23 @@ module Przn
648
2747
  when :tag
649
2748
  tag_name = segment[2]
650
2749
  if (scale = Parser::SIZE_SCALES[tag_name])
651
- KittyText.sized(content, s: scale, f: f, h: h)
652
- elsif Parser::NAMED_COLORS.key?(tag_name)
653
- "#{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}"
654
2757
  else
655
- 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)
656
2759
  end
657
- when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
658
- when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
659
- when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
660
- when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
661
- when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
662
- when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
663
- 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)
664
2767
  end
665
2768
  }.join
666
2769
  body_open.empty? ? inner : "#{body_open}#{inner}#{ANSI[:reset]}"
@@ -673,7 +2776,20 @@ module Przn
673
2776
  # Wrap parsed inline segments into lines that fit within max_width units,
674
2777
  # where 1 unit = `para_scale` terminal cells. Per-segment scaling (e.g.
675
2778
  # size tags) is honored so a span with a larger scale consumes more budget.
676
- 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
+
677
2793
  return [segments] if max_width <= 0
678
2794
 
679
2795
  max_cells = max_width * para_scale
@@ -775,44 +2891,66 @@ module Przn
775
2891
  text + ' ' * [target_width - current, 0].max
776
2892
  end
777
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.
778
2902
  def max_inline_scale(text)
779
2903
  max = 0
780
- text.scan(/\{::tag\s+name="([^"]+)"\}/) do
781
- scale = Parser::SIZE_SCALES[$1]
782
- 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
783
2914
  end
784
2915
  max > 0 ? max : nil
785
2916
  end
786
2917
 
787
2918
  def visible_width_scaled(text, default_scale)
788
2919
  Parser.parse_inline(text).sum { |segment|
789
- type = segment[0]
790
- content = segment[1]
791
- case type
792
- when :tag
793
- scale = Parser::SIZE_SCALES[segment[2]] || default_scale
794
- display_width(content) * scale
795
- else
796
- display_width(content) * default_scale
797
- end
2920
+ content = segment[1] || ''
2921
+ display_width(content) * effective_seg_scale(segment, default_scale)
798
2922
  }
799
2923
  end
800
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.
801
2939
  def color_code(color)
802
- 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])
803
2945
  "\e[#{code}m"
804
- elsif color.match?(/\A[0-9a-fA-F]{6}\z/)
805
- 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) }
806
2948
  "\e[38;2;#{r};#{g};#{b}m"
807
2949
  else
808
2950
  ''
809
2951
  end
810
2952
  end
811
2953
 
812
- def visible_length(text)
813
- display_width(strip_markup(text))
814
- end
815
-
816
2954
  def display_width(str)
817
2955
  str.each_char.sum { |c|
818
2956
  o = c.ord
@@ -838,104 +2976,5 @@ module Przn
838
2976
  }
839
2977
  end
840
2978
 
841
- def strip_markup(text)
842
- text
843
- .gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
844
- .gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
845
- .gsub('{::wait/}', '')
846
- .gsub(/\*\*(.+?)\*\*/, '\1')
847
- .gsub(/\*(.+?)\*/, '\1')
848
- .gsub(/~~(.+?)~~/, '\1')
849
- .gsub(/`([^`]+)`/, '\1')
850
- .gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
851
- end
852
-
853
- def calculate_height(blocks, width)
854
- blocks.sum { |b| block_height(b, width) }
855
- end
856
-
857
- def block_height(block, width)
858
- s = DEFAULT_SCALE
859
- left = content_left(width)
860
- max_w = max_text_width(width, left, s)
861
-
862
- case block[:type]
863
- when :heading
864
- scale = KittyText::HEADING_SCALES[block[:level]] || s
865
- if block[:level] == 1
866
- scale + 4
867
- else
868
- lines_count(block[:content], [max_w - 2, 1].max) * scale
869
- end
870
- when :paragraph
871
- para_scale = max_inline_scale(block[:content]) || s
872
- lines_count(block[:content], [max_text_width(width, left, para_scale), 1].max) * para_scale
873
- when :code_block
874
- [block[:content].lines.size * s, s].max
875
- when :unordered_list
876
- block[:items].sum { |item|
877
- prefix_w = (item[:depth] || 0) * 2 + 2
878
- lines_count(item[:text], [max_w - prefix_w, 1].max) * s
879
- }
880
- when :ordered_list
881
- block[:items].size * s
882
- when :definition_list
883
- term_lines = lines_count(block[:term], [max_w, 1].max)
884
- def_lines = block[:definition].lines.sum { |l| lines_count(l.chomp, [max_w - 4, 1].max) }
885
- (term_lines + def_lines) * s
886
- when :blockquote
887
- block[:content].lines.sum { |l| lines_count(l.chomp, [max_w - 3, 1].max) } * s
888
- when :table
889
- ((block[:header] ? 2 : 0) + block[:rows].size) * s
890
- when :image
891
- # Absolute-positioned images (`<img x y src/>`) layer on top of
892
- # the slide and contribute 0 to the layout — same treatment as :at.
893
- attrs = block[:attrs] || {}
894
- if resolve_at_coord(attrs['x'], @terminal.width) && resolve_at_coord(attrs['y'], @terminal.height)
895
- 0
896
- else
897
- image_block_height(block, width)
898
- end
899
- when :align
900
- 0
901
- when :bg
902
- 0
903
- when :at
904
- 0
905
- when :blank
906
- s
907
- else
908
- s
909
- end
910
- end
911
-
912
- def lines_count(text, max_width)
913
- vis_w = display_width(strip_markup(text))
914
- return 1 if vis_w <= max_width
915
- (vis_w.to_f / max_width).ceil
916
- end
917
-
918
- def image_block_height(block, width)
919
- path = resolve_image_path(block[:path])
920
- img_size = ImageUtil.image_size(path)
921
- return DEFAULT_SCALE unless img_size
922
-
923
- img_w, img_h = img_size
924
- cell_w, cell_h = @terminal.cell_pixel_size
925
- h = @terminal.height
926
-
927
- left = content_left(width)
928
- available_cols = width - left * 2
929
- available_rows = h / 2
930
-
931
- if (rh = block[:attrs]['relative_height'])
932
- available_rows = (h * rh.to_i / 100.0).to_i
933
- end
934
-
935
- img_cell_w = img_w.to_f / cell_w
936
- img_cell_h = img_h.to_f / cell_h
937
- scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
938
- [(img_cell_h * scale).ceil, 1].max
939
- end
940
2979
  end
941
2980
  end