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/controller.rb
CHANGED
|
@@ -2,72 +2,389 @@
|
|
|
2
2
|
|
|
3
3
|
module Przn
|
|
4
4
|
class Controller
|
|
5
|
-
|
|
5
|
+
# `source_file:` / `theme_path:` enable the `r` key to re-read the
|
|
6
|
+
# deck (and the theme.yml, if one is in play) from disk so authors
|
|
7
|
+
# can iterate on the markdown without restarting the session.
|
|
8
|
+
# `theme_path` is the explicit `--theme PATH` value; when nil, the
|
|
9
|
+
# auto-discover path is re-run on each reload (so dropping a
|
|
10
|
+
# `theme.yml` next to the deck mid-session also takes effect).
|
|
11
|
+
def initialize(presentation, terminal, renderer, audience_link: nil,
|
|
12
|
+
source_file: nil, theme_path: nil, watch: true)
|
|
6
13
|
@presentation = presentation
|
|
7
14
|
@terminal = terminal
|
|
8
15
|
@renderer = renderer
|
|
9
16
|
@audience_link = audience_link
|
|
17
|
+
@source_file = source_file
|
|
18
|
+
@theme_path = theme_path
|
|
19
|
+
@watch_enabled = watch && !@source_file.nil?
|
|
10
20
|
@preload_gen = 0
|
|
21
|
+
# Within-slide step counter for incremental reveals (one
|
|
22
|
+
# bump per `<wait/>`). Space walks these before flipping to
|
|
23
|
+
# the next slide; arrow-left walks back into the previous
|
|
24
|
+
# slide's last step. Always resets to 0 on slide jumps and
|
|
25
|
+
# reloads.
|
|
26
|
+
@slide_step = 0
|
|
11
27
|
end
|
|
12
28
|
|
|
29
|
+
# Seconds between runner-bar refresh ticks. The 🐢 only crawls
|
|
30
|
+
# across the track over the full talk's duration, so once a second
|
|
31
|
+
# is plenty for visible movement without being noisy.
|
|
32
|
+
RUNNER_BAR_TICK_SECONDS = 1.0
|
|
33
|
+
|
|
34
|
+
# One decoded SGR mouse report. `button` is 0 (left) / 1 (middle) /
|
|
35
|
+
# 2 (right) / 64+ (wheel); `kind` is :press, :release or :drag
|
|
36
|
+
# (motion with a button held). `col` / `row` are 1-based cells.
|
|
37
|
+
Mouse = Struct.new(:button, :col, :row, :kind)
|
|
38
|
+
|
|
39
|
+
# An in-progress 🐇 drag: where on the glyph it was grabbed, and the
|
|
40
|
+
# slide index the rabbit currently sits at.
|
|
41
|
+
RunnerDrag = Struct.new(:offset, :target)
|
|
42
|
+
|
|
13
43
|
def run
|
|
14
44
|
@started_at = Time.now
|
|
15
45
|
@terminal.enter_alt_screen
|
|
16
46
|
@terminal.hide_cursor
|
|
47
|
+
@terminal.enable_mouse
|
|
48
|
+
EchoesClient.hide_pointer(io_out: @terminal)
|
|
49
|
+
warm_code_highlight_cache
|
|
17
50
|
render_current
|
|
51
|
+
start_runner_bar_thread
|
|
52
|
+
start_file_watcher
|
|
18
53
|
|
|
19
54
|
@terminal.raw do
|
|
20
55
|
loop do
|
|
21
|
-
case
|
|
56
|
+
case (event = read_event)
|
|
57
|
+
when Mouse
|
|
58
|
+
handle_mouse(event)
|
|
22
59
|
when :right, :down, 'l', 'j', ' '
|
|
23
|
-
|
|
24
|
-
render_current
|
|
60
|
+
advance_step_or_slide
|
|
25
61
|
when :left, :up, 'h', 'k'
|
|
26
|
-
|
|
27
|
-
render_current
|
|
62
|
+
retreat_step_or_slide
|
|
28
63
|
when 'g'
|
|
29
64
|
@presentation.first_slide!
|
|
65
|
+
@slide_step = 0
|
|
30
66
|
render_current
|
|
31
67
|
when 'G'
|
|
32
68
|
@presentation.last_slide!
|
|
69
|
+
@slide_step = 0
|
|
33
70
|
render_current
|
|
71
|
+
when 'r'
|
|
72
|
+
reload
|
|
34
73
|
when 'q', "\x03"
|
|
35
74
|
break
|
|
36
75
|
end
|
|
37
76
|
end
|
|
38
77
|
end
|
|
39
78
|
ensure
|
|
79
|
+
stop_file_watcher
|
|
80
|
+
stop_runner_bar_thread
|
|
40
81
|
@preload_gen += 1
|
|
41
82
|
@preload_thread&.join
|
|
83
|
+
@warmup_thread&.kill
|
|
84
|
+
@warmup_thread&.join
|
|
42
85
|
if @audience_link
|
|
43
|
-
AudienceLink.
|
|
86
|
+
AudienceLink.send_message(@audience_link, type: "quit")
|
|
44
87
|
@audience_link.close
|
|
45
88
|
end
|
|
46
89
|
@terminal.write "\e]7772;bg-clear\a"
|
|
90
|
+
@terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
|
|
47
91
|
@terminal.show_cursor
|
|
92
|
+
EchoesClient.show_pointer(io_out: @terminal)
|
|
48
93
|
@terminal.leave_alt_screen
|
|
49
94
|
end
|
|
50
95
|
|
|
51
96
|
private
|
|
52
97
|
|
|
98
|
+
# Re-read the source markdown (and theme.yml, if any) from disk
|
|
99
|
+
# and re-render. Any IO / parse error is swallowed so a bad save
|
|
100
|
+
# mid-edit doesn't kill the running session — the user just sees
|
|
101
|
+
# the previous slide until the next reload succeeds.
|
|
102
|
+
#
|
|
103
|
+
# `jump_to_change:` lets the auto-watcher request a jump to the
|
|
104
|
+
# first slide whose source markdown changed (so a save lands the
|
|
105
|
+
# presenter on the slide they just edited). The manual `r`
|
|
106
|
+
# binding keeps the historical behaviour: stay on the current
|
|
107
|
+
# slide index, clamped to the new deck's bounds.
|
|
108
|
+
def reload(jump_to_change: false)
|
|
109
|
+
return unless @source_file
|
|
110
|
+
markdown = File.read(@source_file)
|
|
111
|
+
new_presentation = Parser.parse(markdown)
|
|
112
|
+
return if new_presentation.total.zero?
|
|
113
|
+
|
|
114
|
+
new_theme =
|
|
115
|
+
if @theme_path
|
|
116
|
+
Theme.load(@theme_path)
|
|
117
|
+
else
|
|
118
|
+
Theme.auto_discover(near: @source_file)
|
|
119
|
+
end
|
|
120
|
+
# auto_discover returns nil when no theme.yml sits next to the
|
|
121
|
+
# deck; fall back to Theme.default so the renderer never has a
|
|
122
|
+
# nil theme (matching what Renderer#initialize does on first
|
|
123
|
+
# construction).
|
|
124
|
+
new_theme ||= Theme.default
|
|
125
|
+
|
|
126
|
+
target = jump_to_change ? first_changed_slide(@last_source, markdown) : nil
|
|
127
|
+
target ||= [@presentation.current, new_presentation.total - 1].min
|
|
128
|
+
new_presentation.go_to([target, new_presentation.total - 1].min)
|
|
129
|
+
@presentation = new_presentation
|
|
130
|
+
@last_source = markdown
|
|
131
|
+
@renderer.theme = new_theme
|
|
132
|
+
# Refresh the renderer's deck-wide ref index — without this an
|
|
133
|
+
# `r` reload would leave <ref id="..."/> resolving against the
|
|
134
|
+
# old presentation's blocks.
|
|
135
|
+
@renderer.presentation = new_presentation
|
|
136
|
+
@slide_step = 0
|
|
137
|
+
render_current
|
|
138
|
+
rescue StandardError
|
|
139
|
+
# Don't crash the session on a bad edit; keep showing the old deck.
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Split the old and new source markdown on `# ` h1 boundaries and
|
|
143
|
+
# return the index of the first chunk that differs — i.e., the
|
|
144
|
+
# slide the author most likely just edited. Falls back to nil
|
|
145
|
+
# when nothing differs (no jump) or when there's no prior snapshot
|
|
146
|
+
# yet (first reload after start — `r` semantics).
|
|
147
|
+
def first_changed_slide(old_src, new_src)
|
|
148
|
+
return nil unless old_src
|
|
149
|
+
old_chunks = split_into_slide_chunks(old_src).map { |c| c.rstrip }
|
|
150
|
+
new_chunks = split_into_slide_chunks(new_src).map { |c| c.rstrip }
|
|
151
|
+
idx = (0...[old_chunks.size, new_chunks.size].max).find { |i|
|
|
152
|
+
old_chunks[i] != new_chunks[i]
|
|
153
|
+
}
|
|
154
|
+
idx
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def split_into_slide_chunks(src)
|
|
158
|
+
chunks = []
|
|
159
|
+
current = +''
|
|
160
|
+
src.each_line do |line|
|
|
161
|
+
if line.start_with?('# ') && !current.empty?
|
|
162
|
+
chunks << current
|
|
163
|
+
current = +''
|
|
164
|
+
end
|
|
165
|
+
current << line
|
|
166
|
+
end
|
|
167
|
+
chunks << current unless current.empty?
|
|
168
|
+
chunks
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Background polling watcher over the source markdown and
|
|
172
|
+
# (optionally) the deck's theme.yml. On change it pushes one byte
|
|
173
|
+
# into `@reload_pipe_w` to wake `read_event`'s select loop; the
|
|
174
|
+
# main thread then runs `reload` synchronously. Idempotent — a
|
|
175
|
+
# burst of writes (some editors save atomically with multiple
|
|
176
|
+
# mtime ticks) collapses into one reload via select's "ready
|
|
177
|
+
# again" semantics.
|
|
178
|
+
def start_file_watcher
|
|
179
|
+
return unless @watch_enabled
|
|
180
|
+
@last_source = File.read(@source_file) rescue nil
|
|
181
|
+
@reload_pipe_r, @reload_pipe_w = IO.pipe
|
|
182
|
+
paths = [@source_file]
|
|
183
|
+
paths << @theme_path if @theme_path
|
|
184
|
+
paths << File.join(File.dirname(File.expand_path(@source_file)), 'theme.yml') unless @theme_path
|
|
185
|
+
@file_watcher = FileWatcher.new(paths)
|
|
186
|
+
@file_watcher.start do |changed|
|
|
187
|
+
# Source-file change → request a jump-to-edited-slide; theme
|
|
188
|
+
# / sidecar changes only re-render in place.
|
|
189
|
+
tag = changed == @source_file ? 's' : 't'
|
|
190
|
+
begin
|
|
191
|
+
@reload_pipe_w.write_nonblock(tag)
|
|
192
|
+
rescue IO::WaitWritable, Errno::EPIPE
|
|
193
|
+
# Pipe full or closed during teardown — drop the signal;
|
|
194
|
+
# the next save will write its own.
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def stop_file_watcher
|
|
200
|
+
@file_watcher&.stop
|
|
201
|
+
@reload_pipe_w&.close
|
|
202
|
+
@reload_pipe_r&.close
|
|
203
|
+
rescue StandardError
|
|
204
|
+
end
|
|
205
|
+
|
|
53
206
|
def render_current
|
|
54
207
|
@renderer.render(
|
|
55
208
|
@presentation.current_slide,
|
|
56
209
|
current: @presentation.current,
|
|
57
210
|
total: @presentation.total,
|
|
58
|
-
started_at: @started_at
|
|
211
|
+
started_at: @started_at,
|
|
212
|
+
step: @slide_step
|
|
59
213
|
)
|
|
60
214
|
if @audience_link
|
|
61
|
-
AudienceLink.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
215
|
+
AudienceLink.send_message(@audience_link,
|
|
216
|
+
type: "goto",
|
|
217
|
+
index: @presentation.current,
|
|
218
|
+
started_at: @started_at.to_f)
|
|
65
219
|
end
|
|
66
220
|
schedule_preload
|
|
67
221
|
end
|
|
68
222
|
|
|
223
|
+
# Space / right / down: reveal the next step on the current slide
|
|
224
|
+
# if there's one left; otherwise jump to the next slide (and reset
|
|
225
|
+
# the step counter to 0 so the new slide starts collapsed).
|
|
226
|
+
#
|
|
227
|
+
# When the just-revealed step contains an animated `<action
|
|
228
|
+
# duration="...">`, run a synchronous animation loop that emits
|
|
229
|
+
# `progress` frames at 30fps before settling on the final
|
|
230
|
+
# `render_current`. The loop blocks the key handler — additional
|
|
231
|
+
# key presses queue and process after the animation finishes.
|
|
232
|
+
def advance_step_or_slide
|
|
233
|
+
total = @renderer.step_count(@presentation.current_slide)
|
|
234
|
+
if @slide_step < total - 1
|
|
235
|
+
@slide_step += 1
|
|
236
|
+
duration_ms = @renderer.max_duration_for_step(@presentation.current_slide, @slide_step)
|
|
237
|
+
if duration_ms > 0
|
|
238
|
+
animate_step(duration_ms)
|
|
239
|
+
else
|
|
240
|
+
render_current
|
|
241
|
+
end
|
|
242
|
+
elsif @presentation.last_slide?
|
|
243
|
+
# Nothing further to advance to — keep the slide rendered as-is
|
|
244
|
+
# rather than running a fade-out that would land on the same slide.
|
|
245
|
+
render_current
|
|
246
|
+
else
|
|
247
|
+
cross_slide_boundary { @presentation.next_slide; @slide_step = 0 }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Default frame rate for the animation loop. 30fps is plenty for
|
|
252
|
+
# the kinds of single-element moves przn does — and on a busy
|
|
253
|
+
# slide every frame triggers a full slide re-render plus an SVG
|
|
254
|
+
# re-upload for any moving shape, so doubling to 60fps would
|
|
255
|
+
# double Echoes' upload churn without obvious benefit.
|
|
256
|
+
ANIMATION_FPS = 30.0
|
|
257
|
+
|
|
258
|
+
# Monotonic-clock animation loop. Emits intermediate
|
|
259
|
+
# `Renderer#render(progress:)` calls until `progress` hits 1.0,
|
|
260
|
+
# then calls `render_current` once more so the post-render
|
|
261
|
+
# audience_link push and `schedule_preload` (skipped per-frame)
|
|
262
|
+
# run at the canonical state.
|
|
263
|
+
def animate_step(duration_ms)
|
|
264
|
+
frame_ms = 1000.0 / ANIMATION_FPS
|
|
265
|
+
slide = @presentation.current_slide
|
|
266
|
+
current = @presentation.current
|
|
267
|
+
total = @presentation.total
|
|
268
|
+
started = monotonic_ms
|
|
269
|
+
loop do
|
|
270
|
+
elapsed = monotonic_ms - started
|
|
271
|
+
progress = (elapsed / duration_ms).clamp(0.0, 1.0)
|
|
272
|
+
@renderer.render(
|
|
273
|
+
slide,
|
|
274
|
+
current: current,
|
|
275
|
+
total: total,
|
|
276
|
+
started_at: @started_at,
|
|
277
|
+
step: @slide_step,
|
|
278
|
+
progress: progress
|
|
279
|
+
)
|
|
280
|
+
break if progress >= 1.0
|
|
281
|
+
sleep_for = (frame_ms - (monotonic_ms - started - elapsed)) / 1000.0
|
|
282
|
+
sleep(sleep_for) if sleep_for > 0
|
|
283
|
+
end
|
|
284
|
+
render_current
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def monotonic_ms
|
|
288
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Wrap a slide change with optional fade-out / fade-in halves so a
|
|
292
|
+
# slide opted into the fade transition via `# Title {transition=fade}`
|
|
293
|
+
# animates instead of snapping. The two halves are independent:
|
|
294
|
+
# leaving slide A fades out iff A opted in; arriving at slide B fades
|
|
295
|
+
# in iff B opted in. Block yields the slide-change mutation (next or
|
|
296
|
+
# prev) sandwiched between the halves.
|
|
297
|
+
#
|
|
298
|
+
# Gated on Echoes — non-Echoes terminals skip the fade entirely and
|
|
299
|
+
# just snap, so the OSC 7772 cell-alpha bytes don't reach a terminal
|
|
300
|
+
# that would drop them and leave the loop's per-frame re-renders
|
|
301
|
+
# visible as flicker.
|
|
302
|
+
def cross_slide_boundary
|
|
303
|
+
outgoing = @presentation.current_slide
|
|
304
|
+
duration = slide_fade_duration_ms
|
|
305
|
+
can_fade = ImageUtil.echoes_terminal? && duration > 0
|
|
306
|
+
out_fade = can_fade && fade_slide?(outgoing)
|
|
307
|
+
|
|
308
|
+
animate_slide_fade(outgoing, from: 1.0, to: 0.0, duration_ms: duration) if out_fade
|
|
309
|
+
yield
|
|
310
|
+
incoming = @presentation.current_slide
|
|
311
|
+
in_fade = can_fade && fade_slide?(incoming)
|
|
312
|
+
animate_slide_fade(incoming, from: 0.0, to: 1.0, duration_ms: duration) if in_fade
|
|
313
|
+
render_current
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# `{transition=fade}` on the slide's h1 IAL → @attrs[:transition] == "fade".
|
|
317
|
+
# Symbols and strings both accepted for forward-compat.
|
|
318
|
+
def fade_slide?(slide)
|
|
319
|
+
v = slide&.attrs && slide.attrs[:transition]
|
|
320
|
+
v.to_s == 'fade'
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def slide_fade_duration_ms
|
|
324
|
+
theme = @renderer.respond_to?(:theme) ? @renderer.theme : nil
|
|
325
|
+
((theme && theme.transition && theme.transition[:duration]) || 200).to_i
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Per-half slide fade — same shape as `animate_step` but lerps a
|
|
329
|
+
# slide-level `opacity:` from `from` → `to` over `duration_ms`.
|
|
330
|
+
def animate_slide_fade(slide, from:, to:, duration_ms:)
|
|
331
|
+
frame_ms = 1000.0 / ANIMATION_FPS
|
|
332
|
+
current = @presentation.current
|
|
333
|
+
total = @presentation.total
|
|
334
|
+
started = monotonic_ms
|
|
335
|
+
loop do
|
|
336
|
+
elapsed = monotonic_ms - started
|
|
337
|
+
progress = (elapsed / duration_ms.to_f).clamp(0.0, 1.0)
|
|
338
|
+
alpha = from + (to - from) * progress
|
|
339
|
+
@renderer.render(
|
|
340
|
+
slide,
|
|
341
|
+
current: current,
|
|
342
|
+
total: total,
|
|
343
|
+
started_at: @started_at,
|
|
344
|
+
step: @slide_step,
|
|
345
|
+
opacity: alpha
|
|
346
|
+
)
|
|
347
|
+
break if progress >= 1.0
|
|
348
|
+
sleep_for = (frame_ms - (monotonic_ms - started - elapsed)) / 1000.0
|
|
349
|
+
sleep(sleep_for) if sleep_for > 0
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Left / up: step backward within the current slide if there's
|
|
354
|
+
# an earlier step to fall back to; otherwise jump to the previous
|
|
355
|
+
# slide and LAND on its last step so backtracking shows the full
|
|
356
|
+
# slide instead of its initial-collapsed view. Mirrors how Keynote
|
|
357
|
+
# walks builds in reverse.
|
|
358
|
+
def retreat_step_or_slide
|
|
359
|
+
if @slide_step > 0
|
|
360
|
+
@slide_step -= 1
|
|
361
|
+
render_current
|
|
362
|
+
else
|
|
363
|
+
prev_idx = @presentation.current - 1
|
|
364
|
+
if prev_idx < 0
|
|
365
|
+
# Already on slide 0; stay put. No fade.
|
|
366
|
+
render_current
|
|
367
|
+
else
|
|
368
|
+
cross_slide_boundary do
|
|
369
|
+
@presentation.prev_slide
|
|
370
|
+
@slide_step = [@renderer.step_count(@presentation.current_slide) - 1, 0].max
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Number of slides on each side of the current slide to pre-upload
|
|
377
|
+
# in the background. Higher = snappier multi-step navigation but
|
|
378
|
+
# more startup work per slide change (and more memory tied up in
|
|
379
|
+
# Echoes' image cache). 3 covers the "I'm about to skip a couple
|
|
380
|
+
# of slides ahead" case without crowding out the LRU when shape-
|
|
381
|
+
# heavy decks have lots of image_ids per slide.
|
|
382
|
+
PRELOAD_RADIUS = 3
|
|
383
|
+
|
|
69
384
|
# Kick off a background thread that pre-uploads images for the slides the
|
|
70
|
-
# user is most likely to visit next
|
|
385
|
+
# user is most likely to visit next. Walks PRELOAD_RADIUS slides in each
|
|
386
|
+
# direction, interleaved (next, prev, next+1, prev+1, …) so the most-
|
|
387
|
+
# likely visit gets warmed first if the user keys quickly. Uses a
|
|
71
388
|
# generation counter so a navigation that lands while a preload is still
|
|
72
389
|
# running causes that preload to exit early instead of stacking work.
|
|
73
390
|
def schedule_preload
|
|
@@ -75,7 +392,8 @@ module Przn
|
|
|
75
392
|
gen = @preload_gen
|
|
76
393
|
cur = @presentation.current
|
|
77
394
|
total = @presentation.total
|
|
78
|
-
indices =
|
|
395
|
+
indices = (1..PRELOAD_RADIUS).flat_map { |d| [cur + d, cur - d] }
|
|
396
|
+
.select { |i| i.between?(0, total - 1) }
|
|
79
397
|
return if indices.empty?
|
|
80
398
|
|
|
81
399
|
@preload_thread = Thread.new do
|
|
@@ -88,6 +406,72 @@ module Przn
|
|
|
88
406
|
end
|
|
89
407
|
end
|
|
90
408
|
|
|
409
|
+
# Fire off a background thread at session start that runs every
|
|
410
|
+
# fenced code block in the deck through CodeHighlighter so the
|
|
411
|
+
# `require 'rouge'` + per-lexer autoload + per-block tokenize
|
|
412
|
+
# costs land *here* instead of on the main render path. By the
|
|
413
|
+
# time the user navigates anywhere, the cache is already warm.
|
|
414
|
+
# Slide 0 races the first render — whichever side tokenizes
|
|
415
|
+
# first writes the cache and the other side finds the hit; no
|
|
416
|
+
# correctness issue, just an "if it wins, the first visit is
|
|
417
|
+
# fast too" optimization. Background work, never blocks startup,
|
|
418
|
+
# silent on failure.
|
|
419
|
+
def warm_code_highlight_cache
|
|
420
|
+
@warmup_thread = Thread.new do
|
|
421
|
+
@presentation.slides.each do |slide|
|
|
422
|
+
slide.blocks.each do |block|
|
|
423
|
+
next unless block[:type] == :code_block && block[:language]
|
|
424
|
+
CodeHighlighter.highlight(block[:content], block[:language])
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
rescue StandardError
|
|
428
|
+
# Background warmup must never crash the session.
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Spawn a thread that calls `redraw_runner_bar` once per second so
|
|
433
|
+
# the 🐢 turtle visibly crawls across the screen against real time
|
|
434
|
+
# instead of only updating on slide navigation. No-op when the
|
|
435
|
+
# theme didn't opt into the runner bar (no `counter.duration`) or
|
|
436
|
+
# we're in export / audience-only modes.
|
|
437
|
+
def start_runner_bar_thread
|
|
438
|
+
return unless runner_bar_thread_eligible?
|
|
439
|
+
@runner_bar_stop = false
|
|
440
|
+
@runner_bar_thread = Thread.new do
|
|
441
|
+
until @runner_bar_stop
|
|
442
|
+
sleep RUNNER_BAR_TICK_SECONDS
|
|
443
|
+
break if @runner_bar_stop
|
|
444
|
+
begin
|
|
445
|
+
@renderer.redraw_runner_bar(
|
|
446
|
+
current: @presentation.current,
|
|
447
|
+
total: @presentation.total,
|
|
448
|
+
started_at: @started_at
|
|
449
|
+
)
|
|
450
|
+
rescue StandardError
|
|
451
|
+
# Background work must not crash the presentation.
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def stop_runner_bar_thread
|
|
458
|
+
@runner_bar_stop = true
|
|
459
|
+
# Wake the sleep so the thread exits promptly instead of waiting
|
|
460
|
+
# out the full tick interval on quit.
|
|
461
|
+
@runner_bar_thread&.wakeup rescue nil
|
|
462
|
+
@runner_bar_thread&.join
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def runner_bar_thread_eligible?
|
|
466
|
+
return false unless @renderer.respond_to?(:redraw_runner_bar)
|
|
467
|
+
return false if @renderer.respond_to?(:export_mode) && @renderer.export_mode
|
|
468
|
+
theme = @renderer.respond_to?(:theme) ? @renderer.theme : nil
|
|
469
|
+
return false unless theme
|
|
470
|
+
counter = theme.counter
|
|
471
|
+
duration = counter && counter[:duration]
|
|
472
|
+
duration && Theme.parse_duration(duration).to_i.positive?
|
|
473
|
+
end
|
|
474
|
+
|
|
91
475
|
def read_key
|
|
92
476
|
c = $stdin.getc
|
|
93
477
|
return nil unless c
|
|
@@ -101,6 +485,7 @@ module Przn
|
|
|
101
485
|
when '[D' then :left
|
|
102
486
|
when '[H' then :home
|
|
103
487
|
when '[F' then :end
|
|
488
|
+
when '[<' then read_mouse
|
|
104
489
|
else :escape
|
|
105
490
|
end
|
|
106
491
|
else
|
|
@@ -109,5 +494,112 @@ module Przn
|
|
|
109
494
|
rescue IO::WaitReadable
|
|
110
495
|
:escape
|
|
111
496
|
end
|
|
497
|
+
|
|
498
|
+
# SGR mouse report (`Terminal#enable_mouse`), the `ESC [ <` already
|
|
499
|
+
# consumed: `Cb ; Cx ; Cy` then `M` on press / drag, `m` on release.
|
|
500
|
+
# Always returns a Mouse (or :mouse for a garbled report) so the
|
|
501
|
+
# whole sequence is swallowed rather than leaking digits into the
|
|
502
|
+
# key loop.
|
|
503
|
+
def read_mouse
|
|
504
|
+
params = +''
|
|
505
|
+
while (c = $stdin.getc)
|
|
506
|
+
break if c == 'M' || c == 'm'
|
|
507
|
+
return :mouse if params.size > 16
|
|
508
|
+
params << c
|
|
509
|
+
end
|
|
510
|
+
return :mouse unless c
|
|
511
|
+
|
|
512
|
+
cb, col, row = params.split(';').map(&:to_i)
|
|
513
|
+
kind = if c == 'm' then :release
|
|
514
|
+
elsif cb & 32 != 0 then :drag
|
|
515
|
+
else :press
|
|
516
|
+
end
|
|
517
|
+
# Strip the shift / meta / ctrl modifier bits (4 / 8 / 16) and the
|
|
518
|
+
# motion bit (32), so a modified click still counts as a click.
|
|
519
|
+
Mouse.new(cb & ~0b111100, col, row, kind)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Left-click is next, right-click is previous — except a left press
|
|
523
|
+
# on the 🐇 of the runner bar, which grabs it: dragging scrubs the
|
|
524
|
+
# deck, the slide following the rabbit along the track.
|
|
525
|
+
def handle_mouse(ev)
|
|
526
|
+
if @runner_drag && ev.kind != :press
|
|
527
|
+
drag_runner(ev)
|
|
528
|
+
return
|
|
529
|
+
end
|
|
530
|
+
@runner_drag = nil
|
|
531
|
+
return unless ev.kind == :press
|
|
532
|
+
|
|
533
|
+
case ev.button
|
|
534
|
+
when 0
|
|
535
|
+
offset = @renderer.respond_to?(:runner_grab) &&
|
|
536
|
+
@renderer.runner_grab(ev.row, ev.col, current: @presentation.current, total: @presentation.total)
|
|
537
|
+
if offset
|
|
538
|
+
@runner_drag = RunnerDrag.new(offset, @presentation.current)
|
|
539
|
+
else
|
|
540
|
+
advance_step_or_slide
|
|
541
|
+
end
|
|
542
|
+
when 2
|
|
543
|
+
retreat_step_or_slide
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# A fast drag queues a burst of motion reports; rendering a slide
|
|
548
|
+
# for each would lag behind the pointer. Track the target on every
|
|
549
|
+
# report but render only once the queue is drained (the latest
|
|
550
|
+
# report wins), and settle on release for good measure.
|
|
551
|
+
def drag_runner(ev)
|
|
552
|
+
drag = @runner_drag
|
|
553
|
+
case ev.kind
|
|
554
|
+
when :drag
|
|
555
|
+
return unless ev.button == 0
|
|
556
|
+
drag.target = @renderer.runner_slide_at(ev.col - drag.offset, current: @presentation.current, total: @presentation.total)
|
|
557
|
+
show_runner_target(drag) unless input_pending?
|
|
558
|
+
when :release
|
|
559
|
+
@runner_drag = nil
|
|
560
|
+
show_runner_target(drag)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def show_runner_target(drag)
|
|
565
|
+
return if drag.target == @presentation.current
|
|
566
|
+
@presentation.go_to(drag.target)
|
|
567
|
+
@slide_step = 0
|
|
568
|
+
render_current
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def input_pending?
|
|
572
|
+
!IO.select([$stdin], nil, nil, 0).nil?
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Multiplex stdin and the file-watcher pipe. Without the watcher
|
|
576
|
+
# wired up, this is a thin pass-through to `read_key` so the
|
|
577
|
+
# `--no-watch` / no-source-file paths behave exactly as they did
|
|
578
|
+
# before. With the watcher up, `select` blocks until either a
|
|
579
|
+
# keystroke or a save lands, and a save returns `:reload`.
|
|
580
|
+
def read_event
|
|
581
|
+
return read_key unless @reload_pipe_r
|
|
582
|
+
readable, = IO.select([$stdin, @reload_pipe_r])
|
|
583
|
+
if readable.include?(@reload_pipe_r)
|
|
584
|
+
# Drain whatever the watcher queued so a flurry of saves
|
|
585
|
+
# collapses to a single reload. The tag tells us whether to
|
|
586
|
+
# jump to the modified slide (source change) or stay put
|
|
587
|
+
# (theme change).
|
|
588
|
+
tags = drain_reload_pipe
|
|
589
|
+
reload(jump_to_change: tags.include?('s'))
|
|
590
|
+
nil
|
|
591
|
+
else
|
|
592
|
+
read_key
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def drain_reload_pipe
|
|
597
|
+
tags = +''
|
|
598
|
+
loop do
|
|
599
|
+
tags << @reload_pipe_r.read_nonblock(64)
|
|
600
|
+
end
|
|
601
|
+
rescue IO::WaitReadable, EOFError
|
|
602
|
+
tags
|
|
603
|
+
end
|
|
112
604
|
end
|
|
113
605
|
end
|
data/lib/przn/echoes_client.rb
CHANGED
|
@@ -29,6 +29,26 @@ module Przn
|
|
|
29
29
|
nil
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Tell Echoes to hide the OS mouse pointer over its window — same
|
|
33
|
+
# effect as the user's cmd-shift-p shortcut, but emitted at the
|
|
34
|
+
# start of a presentation so the pointer doesn't reappear every
|
|
35
|
+
# time the presenter brushes the trackpad. Non-Echoes terminals
|
|
36
|
+
# ignore the OSC.
|
|
37
|
+
def hide_pointer(io_out: $stdout)
|
|
38
|
+
io_out.write("#{OSC};hide-pointer#{BEL}")
|
|
39
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Restore the OS mouse pointer hidden by `hide_pointer`. Emitted on
|
|
44
|
+
# presentation teardown so the user gets their cursor back when they
|
|
45
|
+
# quit, instead of having to reach for cmd-shift-p.
|
|
46
|
+
def show_pointer(io_out: $stdout)
|
|
47
|
+
io_out.write("#{OSC};show-pointer#{BEL}")
|
|
48
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
32
52
|
# Open a new Echoes window on the given display, running `argv` (an
|
|
33
53
|
# Array of strings — argv[0] is the executable). `fullscreen:` is a hint.
|
|
34
54
|
# Returns true if the request was emitted; nothing in the protocol confirms
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przn
|
|
4
|
+
# Polling-based watcher over a small fixed set of files. Single
|
|
5
|
+
# background thread checks `File.mtime` every `interval` seconds; the
|
|
6
|
+
# first time a path's mtime moves past the snapshot taken at start,
|
|
7
|
+
# the block runs with that path as its argument.
|
|
8
|
+
#
|
|
9
|
+
# Built around the on-save edit cadence: a human typing in their
|
|
10
|
+
# editor produces at most one save per few seconds, so a 0.5 s poll
|
|
11
|
+
# is well below human-perception latency and saves us a gem dep on
|
|
12
|
+
# rb-fsevent / listen.
|
|
13
|
+
#
|
|
14
|
+
# Paths that don't exist when `start` is called (e.g. an optional
|
|
15
|
+
# `theme.yml` that hasn't been written yet) are watched too — the
|
|
16
|
+
# mtime starts at nil, so the first save trips the change handler.
|
|
17
|
+
class FileWatcher
|
|
18
|
+
DEFAULT_INTERVAL = 0.5
|
|
19
|
+
|
|
20
|
+
def initialize(paths, interval: DEFAULT_INTERVAL)
|
|
21
|
+
@paths = paths.compact
|
|
22
|
+
@interval = interval
|
|
23
|
+
@thread = nil
|
|
24
|
+
@stop = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start(&on_change)
|
|
28
|
+
return if @thread
|
|
29
|
+
mtimes = snapshot
|
|
30
|
+
@stop = false
|
|
31
|
+
@thread = Thread.new do
|
|
32
|
+
Thread.current.abort_on_exception = false
|
|
33
|
+
until @stop
|
|
34
|
+
sleep @interval
|
|
35
|
+
break if @stop
|
|
36
|
+
@paths.each do |path|
|
|
37
|
+
current = mtime_for(path)
|
|
38
|
+
next if current == mtimes[path]
|
|
39
|
+
mtimes[path] = current
|
|
40
|
+
begin
|
|
41
|
+
on_change.call(path)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
# Don't kill the watcher because a single notification
|
|
44
|
+
# raised — the next save still gets a chance.
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stop
|
|
52
|
+
@stop = true
|
|
53
|
+
@thread&.join
|
|
54
|
+
@thread = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def snapshot
|
|
60
|
+
@paths.each_with_object({}) { |p, h| h[p] = mtime_for(p) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def mtime_for(path)
|
|
64
|
+
File.mtime(path)
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|