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