przn 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,73 +2,389 @@
2
2
 
3
3
  module Przn
4
4
  class Controller
5
- def initialize(presentation, terminal, renderer, audience_link: nil)
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 read_key
56
+ case (event = read_event)
57
+ when Mouse
58
+ handle_mouse(event)
22
59
  when :right, :down, 'l', 'j', ' '
23
- @presentation.next_slide
24
- render_current
60
+ advance_step_or_slide
25
61
  when :left, :up, 'h', 'k'
26
- @presentation.prev_slide
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.send(@audience_link, type: "quit")
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"
47
90
  @terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
48
91
  @terminal.show_cursor
92
+ EchoesClient.show_pointer(io_out: @terminal)
49
93
  @terminal.leave_alt_screen
50
94
  end
51
95
 
52
96
  private
53
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
+
54
206
  def render_current
55
207
  @renderer.render(
56
208
  @presentation.current_slide,
57
209
  current: @presentation.current,
58
210
  total: @presentation.total,
59
- started_at: @started_at
211
+ started_at: @started_at,
212
+ step: @slide_step
60
213
  )
61
214
  if @audience_link
62
- AudienceLink.send(@audience_link,
63
- type: "goto",
64
- index: @presentation.current,
65
- started_at: @started_at.to_f)
215
+ AudienceLink.send_message(@audience_link,
216
+ type: "goto",
217
+ index: @presentation.current,
218
+ started_at: @started_at.to_f)
66
219
  end
67
220
  schedule_preload
68
221
  end
69
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
+
70
384
  # Kick off a background thread that pre-uploads images for the slides the
71
- # user is most likely to visit next (the immediate neighbors). Uses a
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
72
388
  # generation counter so a navigation that lands while a preload is still
73
389
  # running causes that preload to exit early instead of stacking work.
74
390
  def schedule_preload
@@ -76,7 +392,8 @@ module Przn
76
392
  gen = @preload_gen
77
393
  cur = @presentation.current
78
394
  total = @presentation.total
79
- indices = [cur + 1, cur - 1].select { |i| i.between?(0, total - 1) }
395
+ indices = (1..PRELOAD_RADIUS).flat_map { |d| [cur + d, cur - d] }
396
+ .select { |i| i.between?(0, total - 1) }
80
397
  return if indices.empty?
81
398
 
82
399
  @preload_thread = Thread.new do
@@ -89,6 +406,72 @@ module Przn
89
406
  end
90
407
  end
91
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
+
92
475
  def read_key
93
476
  c = $stdin.getc
94
477
  return nil unless c
@@ -102,6 +485,7 @@ module Przn
102
485
  when '[D' then :left
103
486
  when '[H' then :home
104
487
  when '[F' then :end
488
+ when '[<' then read_mouse
105
489
  else :escape
106
490
  end
107
491
  else
@@ -110,5 +494,112 @@ module Przn
110
494
  rescue IO::WaitReadable
111
495
  :escape
112
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
113
604
  end
114
605
  end
@@ -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