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.
- checksums.yaml +4 -4
- data/README.md +467 -82
- data/default_theme.yml +98 -18
- data/exe/przn +8 -2
- data/lib/przn/audience_link.rb +3 -3
- data/lib/przn/code_highlighter.rb +165 -0
- data/lib/przn/controller.rb +505 -14
- data/lib/przn/echoes_client.rb +20 -0
- data/lib/przn/file_watcher.rb +69 -0
- data/lib/przn/image_util.rb +55 -2
- data/lib/przn/kitty_text.rb +2 -1
- data/lib/przn/mermaid_renderer.rb +88 -0
- data/lib/przn/parser.rb +341 -29
- data/lib/przn/prawn_pdf_exporter.rb +133 -15
- data/lib/przn/presentation.rb +43 -0
- data/lib/przn/presenter_renderer.rb +22 -8
- data/lib/przn/renderer.rb +2382 -343
- data/lib/przn/screenshot_pdf_exporter.rb +172 -5
- data/lib/przn/slide.rb +4 -2
- data/lib/przn/terminal.rb +10 -0
- data/lib/przn/theme.rb +85 -10
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +10 -5
- data/sample/ruby-logo-LICENSE +15 -0
- data/sample/ruby.jpg +0 -0
- data/sample/ruby.png +0 -0
- data/sample/sample.md +279 -38
- data/sample/shapes.md +55 -0
- metadata +91 -77
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
#
|
|
162
|
-
# `
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
# `
|
|
194
|
-
#
|
|
195
|
-
#
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 "#{
|
|
1858
|
+
@terminal.write "#{open_seq}#{left}#{ANSI[:reset]}"
|
|
207
1859
|
@terminal.move_to(h, w - right.size + 1)
|
|
208
|
-
@terminal.write "#{
|
|
1860
|
+
@terminal.write "#{open_seq}#{right}#{ANSI[:reset]}"
|
|
209
1861
|
|
|
210
|
-
|
|
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
|
|
1865
|
+
@terminal.write counter[:rabbit].to_s
|
|
214
1866
|
|
|
215
|
-
duration_s =
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1937
|
+
wrapped = wrap_segments(segments, max_w, body_scale)
|
|
264
1938
|
|
|
265
1939
|
wrapped.each_with_index do |line_segs, li|
|
|
266
|
-
|
|
1940
|
+
term_move(row, left)
|
|
267
1941
|
if li == 0
|
|
268
|
-
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs,
|
|
1942
|
+
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, body_scale)}"
|
|
269
1943
|
else
|
|
270
|
-
@terminal.write "#{KittyText.sized(' ' * prefix_w, s:
|
|
1944
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: body_scale)}#{render_segments_scaled(line_segs, body_scale)}"
|
|
271
1945
|
end
|
|
272
|
-
row +=
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1972
|
+
max_w = max_text_width(width, left, base_scale)
|
|
289
1973
|
segments = Parser.parse_inline(text)
|
|
290
|
-
wrapped = wrap_segments(segments, max_w,
|
|
1974
|
+
wrapped = wrap_segments(segments, max_w, base_scale)
|
|
291
1975
|
|
|
292
1976
|
wrapped.each do |line_segs|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 +
|
|
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,
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 +=
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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 +=
|
|
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,
|
|
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,
|
|
2259
|
+
wrapped = wrap_segments(segments, max_w, body_scale)
|
|
378
2260
|
wrapped.each do |line_segs|
|
|
379
|
-
|
|
380
|
-
@terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs,
|
|
381
|
-
row +=
|
|
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,
|
|
2269
|
+
wrapped = wrap_segments(segments, def_max_w, body_scale)
|
|
388
2270
|
wrapped.each do |line_segs|
|
|
389
|
-
|
|
390
|
-
@terminal.write render_segments_scaled(line_segs,
|
|
391
|
-
row +=
|
|
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,
|
|
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,
|
|
2288
|
+
wrapped = wrap_segments(segments, max_w, body_scale)
|
|
407
2289
|
|
|
408
2290
|
wrapped.each_with_index do |line_segs, li|
|
|
409
|
-
|
|
2291
|
+
term_move(row, left + 1)
|
|
410
2292
|
p = li == 0 ? prefix : ' ' * prefix_w
|
|
411
|
-
@terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s:
|
|
412
|
-
row +=
|
|
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
|
-
|
|
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:
|
|
2318
|
+
@terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: body_scale)}#{ANSI[:reset]}"
|
|
437
2319
|
else
|
|
438
|
-
@terminal.write KittyText.sized(line, s:
|
|
2320
|
+
@terminal.write KittyText.sized(line, s: body_scale)
|
|
439
2321
|
end
|
|
440
|
-
row +=
|
|
2322
|
+
row += body_scale
|
|
441
2323
|
|
|
442
2324
|
if ri == 0
|
|
443
|
-
|
|
444
|
-
@terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s:
|
|
445
|
-
row +=
|
|
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 +
|
|
2346
|
+
return row + body_scale unless File.exist?(path)
|
|
454
2347
|
|
|
455
2348
|
img_size = ImageUtil.image_size(path)
|
|
456
|
-
return row +
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
#
|
|
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 =
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
652
|
-
elsif
|
|
653
|
-
|
|
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
|
-
|
|
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]}#{
|
|
659
|
-
when :bold then "#{ANSI[:bold]}#{
|
|
660
|
-
when :italic then "#{ANSI[:italic]}#{
|
|
661
|
-
when :strikethrough then "#{ANSI[:strikethrough]}#{
|
|
662
|
-
when :code then "#{ANSI[:gray_bg]}#{
|
|
663
|
-
when :text then
|
|
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 =
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
790
|
-
content
|
|
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
|
-
|
|
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
|
|
805
|
-
r, g, b =
|
|
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
|