prezo 0.3.1__py3-none-any.whl

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.
prezo/app.py ADDED
@@ -0,0 +1,947 @@
1
+ """Prezo - TUI Presentation Tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import termios
12
+ import tty
13
+ from functools import partial
14
+ from pathlib import Path
15
+ from typing import ClassVar
16
+
17
+ from textual.app import App, ComposeResult
18
+ from textual.binding import Binding, BindingType
19
+ from textual.command import Hit, Hits, Provider
20
+ from textual.containers import Horizontal, Vertical, VerticalScroll
21
+ from textual.reactive import reactive
22
+ from textual.widgets import Footer, Header, Markdown, Static
23
+
24
+ from .config import Config, get_config, get_state, save_state
25
+ from .images.ascii import HalfBlockRenderer
26
+ from .images.chafa import chafa_available, render_with_chafa
27
+ from .images.processor import resolve_image_path
28
+ from .parser import Presentation, parse_presentation
29
+ from .screens import (
30
+ BlackoutScreen,
31
+ GotoSlideScreen,
32
+ HelpScreen,
33
+ SlideOverviewScreen,
34
+ SlideSearchScreen,
35
+ TableOfContentsScreen,
36
+ )
37
+ from .terminal import ImageCapability, detect_image_capability
38
+ from .themes import get_next_theme, get_theme
39
+ from .widgets import ImageDisplay, StatusBar
40
+
41
+ WELCOME_MESSAGE = """\
42
+ # Welcome to Prezo
43
+
44
+ A TUI presentation tool.
45
+
46
+ ## Usage
47
+
48
+ ```
49
+ prezo <presentation.md>
50
+ ```
51
+
52
+ ## Navigation
53
+
54
+ | Key | Action |
55
+ |-----|--------|
56
+ | **→** / **j** / **Space** | Next slide |
57
+ | **←** / **k** | Previous slide |
58
+ | **Home** / **g** | First slide |
59
+ | **End** / **G** | Last slide |
60
+ | **:** | Go to slide |
61
+ | **/** | Search slides |
62
+ | **o** | Slide overview |
63
+ | **t** | Table of contents |
64
+ | **p** | Toggle notes |
65
+ | **c** | Toggle clock |
66
+ | **b** | Blackout screen |
67
+ | **e** | Edit current slide |
68
+ | **r** | Reload file |
69
+ | **Ctrl+P** | Command palette |
70
+ | **?** | Help |
71
+ | **q** | Quit |
72
+
73
+ ## Features
74
+
75
+ - **Live reload**: Automatically refreshes when file changes
76
+ - **Edit slides**: Press `e` to edit in $EDITOR
77
+ - **MARP/Deckset** compatible Markdown format
78
+ """
79
+
80
+
81
+ def _format_recent_files(recent_files: list[str], max_files: int = 5) -> str:
82
+ """Format recent files list for display.
83
+
84
+ Args:
85
+ recent_files: List of recent file paths.
86
+ max_files: Maximum number of files to show.
87
+
88
+ Returns:
89
+ Formatted markdown string.
90
+
91
+ """
92
+ if not recent_files:
93
+ return ""
94
+
95
+ lines = ["\n## Recent Files\n"]
96
+ for path_str in recent_files[:max_files]:
97
+ # Show just the filename and parent directory for brevity
98
+ p = Path(path_str)
99
+ if p.exists():
100
+ display = f"{p.parent.name}/{p.name}" if p.parent.name else p.name
101
+ lines.append(f"- `{display}`")
102
+
103
+ if lines == ["\n## Recent Files\n"]:
104
+ return ""
105
+
106
+ return "\n".join(lines)
107
+
108
+
109
+ class PrezoCommands(Provider):
110
+ """Command provider for Prezo actions."""
111
+
112
+ @property
113
+ def _app(self) -> PrezoApp:
114
+ """Get the app instance."""
115
+ return self.app # type: ignore[return-value]
116
+
117
+ async def search(self, query: str) -> Hits:
118
+ """Search for matching commands."""
119
+ matcher = self.matcher(query)
120
+
121
+ # Navigation commands
122
+ commands = [
123
+ ("Next Slide", "next_slide", "Go to the next slide (→/j/Space)"),
124
+ ("Previous Slide", "prev_slide", "Go to the previous slide (←/k)"),
125
+ ("First Slide", "first_slide", "Go to the first slide (Home/g)"),
126
+ ("Last Slide", "last_slide", "Go to the last slide (End/G)"),
127
+ ("Go to Slide...", "goto_slide", "Jump to a specific slide number (:)"),
128
+ ]
129
+
130
+ # View commands
131
+ commands.extend(
132
+ [
133
+ (
134
+ "Slide Overview",
135
+ "show_overview",
136
+ "Show grid overview of all slides (o)",
137
+ ),
138
+ ("Table of Contents", "show_toc", "Show table of contents (t)"),
139
+ ("Search Slides", "search", "Search slides by content (/)"),
140
+ ("Toggle Notes", "toggle_notes", "Show/hide presenter notes (p)"),
141
+ ("Toggle Clock", "toggle_clock", "Cycle clock display mode (c)"),
142
+ ("Help", "show_help", "Show keyboard shortcuts (?)"),
143
+ ]
144
+ )
145
+
146
+ # Theme commands
147
+ commands.extend(
148
+ [
149
+ ("Cycle Theme", "cycle_theme", "Switch to next theme (T)"),
150
+ ("Theme: Dark", "set_theme_dark", "Switch to dark theme"),
151
+ ("Theme: Light", "set_theme_light", "Switch to light theme"),
152
+ ("Theme: Dracula", "set_theme_dracula", "Switch to dracula theme"),
153
+ ("Theme: Nord", "set_theme_nord", "Switch to nord theme"),
154
+ ("Theme: Gruvbox", "set_theme_gruvbox", "Switch to gruvbox theme"),
155
+ ]
156
+ )
157
+
158
+ # Screen commands
159
+ commands.extend(
160
+ [
161
+ ("Blackout Screen", "blackout", "Show black screen (b)"),
162
+ ("Whiteout Screen", "whiteout", "Show white screen (w)"),
163
+ ]
164
+ )
165
+
166
+ # File commands
167
+ commands.extend(
168
+ [
169
+ ("Reload Presentation", "reload", "Reload the presentation file (r)"),
170
+ ("Edit Slide", "edit_slide", "Edit current slide in editor (e)"),
171
+ ("Quit", "quit", "Exit Prezo (q)"),
172
+ ]
173
+ )
174
+
175
+ for name, action, description in commands:
176
+ score = matcher.match(name)
177
+ if score > 0:
178
+ yield Hit(
179
+ score,
180
+ matcher.highlight(name),
181
+ partial(self._run_action, action),
182
+ help=description,
183
+ )
184
+
185
+ async def _run_action(self, action: str) -> None:
186
+ """Run an app action."""
187
+ if action.startswith("set_theme_"):
188
+ theme = action.replace("set_theme_", "")
189
+ self._app.app_theme = theme
190
+ else:
191
+ await self._app.run_action(action)
192
+
193
+
194
+ class PrezoApp(App):
195
+ """A TUI presentation viewer."""
196
+
197
+ ENABLE_COMMAND_PALETTE = True
198
+ COMMAND_PALETTE_BINDING = "ctrl+p"
199
+ COMMANDS: ClassVar[set[type[Provider]]] = {PrezoCommands}
200
+
201
+ CSS = """
202
+ Screen {
203
+ layout: vertical;
204
+ }
205
+
206
+ Header {
207
+ dock: top;
208
+ }
209
+
210
+ Footer {
211
+ dock: bottom;
212
+ }
213
+
214
+ #content-area {
215
+ width: 100%;
216
+ height: 1fr;
217
+ layout: vertical;
218
+ }
219
+
220
+ #main-container {
221
+ width: 100%;
222
+ height: 1fr;
223
+ }
224
+
225
+ #slide-outer {
226
+ width: 1fr;
227
+ height: 100%;
228
+ background: $surface;
229
+ }
230
+
231
+ /* Horizontal container for left/right layouts */
232
+ #slide-horizontal {
233
+ width: 100%;
234
+ height: 100%;
235
+ layout: horizontal;
236
+ }
237
+
238
+ /* Vertical scrolling container for content */
239
+ #slide-container {
240
+ width: 1fr;
241
+ height: 100%;
242
+ padding: 1 4;
243
+ }
244
+
245
+ #slide-content {
246
+ width: 100%;
247
+ padding: 1 2;
248
+ }
249
+
250
+ /* Image container - hidden by default */
251
+ #image-container {
252
+ height: 100%;
253
+ padding: 1 2;
254
+ display: none;
255
+ }
256
+
257
+ #image-container.visible {
258
+ display: block;
259
+ }
260
+
261
+ /* Layout: image on left (default 50%) */
262
+ #image-container.layout-left {
263
+ width: 50%;
264
+ }
265
+
266
+ /* Layout: image on right (default 50%) */
267
+ #image-container.layout-right {
268
+ width: 50%;
269
+ }
270
+
271
+ /* Layout: image inline (above text) */
272
+ #image-container.layout-inline {
273
+ width: 100%;
274
+ }
275
+
276
+ #slide-image {
277
+ width: 100%;
278
+ height: auto;
279
+ }
280
+
281
+ #notes-panel {
282
+ width: 30%;
283
+ height: 100%;
284
+ background: $surface-darken-1;
285
+ border-left: solid $primary;
286
+ padding: 1 2;
287
+ display: none;
288
+ }
289
+
290
+ #notes-panel.visible {
291
+ display: block;
292
+ }
293
+
294
+ #notes-title {
295
+ text-style: bold;
296
+ color: $primary;
297
+ margin-bottom: 1;
298
+ }
299
+
300
+ #notes-content {
301
+ width: 100%;
302
+ }
303
+
304
+ #status-bar {
305
+ width: 100%;
306
+ height: 1;
307
+ background: $primary;
308
+ color: $text;
309
+ text-align: center;
310
+ }
311
+ """
312
+
313
+ BINDINGS: ClassVar[list[BindingType]] = [
314
+ Binding("q", "quit", "Quit"),
315
+ Binding("right", "next_slide", "Next", show=True),
316
+ Binding("left", "prev_slide", "Previous", show=True),
317
+ Binding("j", "next_slide", "Next", show=False),
318
+ Binding("k", "prev_slide", "Previous", show=False),
319
+ Binding("space", "next_slide", "Next", show=False),
320
+ Binding("home", "first_slide", "First"),
321
+ Binding("end", "last_slide", "Last"),
322
+ Binding("g", "first_slide", "First", show=False),
323
+ Binding("G", "last_slide", "Last", show=False),
324
+ Binding("o", "show_overview", "Overview", show=True),
325
+ Binding("colon", "goto_slide", "Go to", show=False),
326
+ Binding("slash", "search", "Search", show=True),
327
+ Binding("t", "show_toc", "TOC", show=True),
328
+ Binding("p", "toggle_notes", "Notes", show=True),
329
+ Binding("c", "toggle_clock", "Clock", show=False),
330
+ Binding("T", "cycle_theme", "Theme", show=False),
331
+ Binding("b", "blackout", "Blackout", show=False),
332
+ Binding("w", "whiteout", "Whiteout", show=False),
333
+ Binding("e", "edit_slide", "Edit", show=False),
334
+ Binding("r", "reload", "Reload", show=False),
335
+ Binding("question_mark", "show_help", "Help", show=True),
336
+ Binding("i", "view_image", "Image", show=False),
337
+ ]
338
+
339
+ current_slide: reactive[int] = reactive(0)
340
+ notes_visible: reactive[bool] = reactive(False)
341
+ app_theme: reactive[str] = reactive("dark")
342
+
343
+ TITLE = "Prezo"
344
+
345
+ def __init__(
346
+ self,
347
+ presentation_path: str | Path | None = None,
348
+ *,
349
+ watch: bool | None = None,
350
+ config: Config | None = None,
351
+ ) -> None:
352
+ """Initialize the Prezo application.
353
+
354
+ Args:
355
+ presentation_path: Path to the Markdown presentation file.
356
+ watch: Whether to enable file watching for live reload.
357
+ config: Optional config override. Uses global config if None.
358
+
359
+ """
360
+ super().__init__()
361
+ self.config = config or get_config()
362
+ self.state = get_state()
363
+
364
+ self.presentation_path = Path(presentation_path) if presentation_path else None
365
+ self.presentation: Presentation | None = None
366
+
367
+ # Use config for watch if not explicitly set
368
+ if watch is None:
369
+ self.watch_enabled = self.config.behavior.auto_reload
370
+ else:
371
+ self.watch_enabled = watch
372
+
373
+ self._file_mtime: float | None = None
374
+ self._watch_timer = None
375
+ self._reload_interval = self.config.behavior.reload_interval
376
+
377
+ def compose(self) -> ComposeResult:
378
+ """Compose the application layout."""
379
+ yield Header()
380
+ with Vertical(id="content-area"):
381
+ with Horizontal(id="main-container"):
382
+ with Vertical(id="slide-outer"):
383
+ with Horizontal(id="slide-horizontal"):
384
+ # Image container (left position) - hidden by default
385
+ with Vertical(id="image-container"):
386
+ yield ImageDisplay(id="slide-image")
387
+ # Text container
388
+ with VerticalScroll(id="slide-container"):
389
+ yield Markdown("", id="slide-content")
390
+ with Vertical(id="notes-panel"):
391
+ yield Static("Notes", id="notes-title")
392
+ yield Markdown("", id="notes-content")
393
+ yield StatusBar(id="status-bar")
394
+ yield Footer()
395
+
396
+ def on_mount(self) -> None:
397
+ """Load presentation when app mounts."""
398
+ # Set theme from config (must be done here, not in __init__, to avoid
399
+ # triggering the watcher before the app has screens)
400
+ self.app_theme = self.config.display.theme
401
+ self.call_after_refresh(self._initial_load)
402
+
403
+ def _initial_load(self) -> None:
404
+ """Load presentation after UI is ready."""
405
+ if self.presentation_path:
406
+ self.load_presentation(self.presentation_path)
407
+ if self.watch_enabled:
408
+ self._start_file_watch()
409
+ else:
410
+ self._show_welcome()
411
+
412
+ def _start_file_watch(self) -> None:
413
+ """Start watching the file for changes."""
414
+ if self.presentation_path and self.presentation_path.exists():
415
+ self._file_mtime = self.presentation_path.stat().st_mtime
416
+ self._watch_timer = self.set_interval(
417
+ self._reload_interval, self._check_file_changes
418
+ )
419
+
420
+ def _check_file_changes(self) -> None:
421
+ """Check if the presentation file has changed."""
422
+ if not self.presentation_path or not self.presentation_path.exists():
423
+ return
424
+
425
+ current_mtime = self.presentation_path.stat().st_mtime
426
+ if self._file_mtime and current_mtime > self._file_mtime:
427
+ self._file_mtime = current_mtime
428
+ self._reload_presentation()
429
+
430
+ def _reload_presentation(self) -> None:
431
+ """Reload the presentation from disk."""
432
+ if not self.presentation_path:
433
+ return
434
+
435
+ old_slide = self.current_slide
436
+ self.presentation = parse_presentation(self.presentation_path)
437
+
438
+ if old_slide >= self.presentation.total_slides:
439
+ self.current_slide = max(0, self.presentation.total_slides - 1)
440
+ else:
441
+ self._update_display()
442
+
443
+ self.notify("Presentation reloaded", timeout=2)
444
+
445
+ def load_presentation(self, path: str | Path) -> None:
446
+ """Load a presentation from a file."""
447
+ self.presentation_path = Path(path)
448
+ self.presentation = parse_presentation(path)
449
+
450
+ # Restore last position or start at 0
451
+ abs_path = str(self.presentation_path.absolute())
452
+ last_pos = self.state.get_position(abs_path)
453
+ if last_pos < self.presentation.total_slides:
454
+ self.current_slide = last_pos
455
+ else:
456
+ self.current_slide = 0
457
+
458
+ self._update_display()
459
+ self._update_progress_bar()
460
+
461
+ if self.presentation.title:
462
+ self.sub_title = self.presentation.title
463
+
464
+ if self.presentation_path.exists():
465
+ self._file_mtime = self.presentation_path.stat().st_mtime
466
+
467
+ # Apply presentation directives on top of config
468
+ self._apply_presentation_directives()
469
+
470
+ # Add to recent files and save state
471
+ self.state.add_recent_file(abs_path)
472
+ save_state(self.state)
473
+
474
+ # Reset timer when loading new presentation
475
+ status_bar = self.query_one("#status-bar", StatusBar)
476
+ self._apply_timer_config(status_bar)
477
+ status_bar.reset_timer()
478
+
479
+ def _apply_presentation_directives(self) -> None:
480
+ """Apply presentation-specific directives on top of config."""
481
+ if not self.presentation:
482
+ return
483
+
484
+ directives = self.presentation.directives
485
+
486
+ # Apply theme from presentation if specified
487
+ if directives.theme:
488
+ self.app_theme = directives.theme
489
+
490
+ def _apply_timer_config(self, status_bar: StatusBar) -> None:
491
+ """Apply timer configuration to the status bar."""
492
+ # Start with config defaults
493
+ show_clock = self.config.timer.show_clock
494
+ show_elapsed = self.config.timer.show_elapsed
495
+ countdown = self.config.timer.countdown_minutes
496
+
497
+ # Override with presentation directives if specified
498
+ if self.presentation:
499
+ directives = self.presentation.directives
500
+ if directives.show_clock is not None:
501
+ show_clock = directives.show_clock
502
+ if directives.show_elapsed is not None:
503
+ show_elapsed = directives.show_elapsed
504
+ if directives.countdown_minutes is not None:
505
+ countdown = directives.countdown_minutes
506
+
507
+ # Apply to status bar
508
+ status_bar.show_clock = show_clock
509
+ status_bar.show_elapsed = show_elapsed
510
+ status_bar.countdown_minutes = countdown
511
+ status_bar.show_countdown = countdown > 0
512
+
513
+ def _show_welcome(self) -> None:
514
+ """Show welcome message when no presentation is loaded."""
515
+ welcome = WELCOME_MESSAGE
516
+ recent_section = _format_recent_files(self.state.recent_files)
517
+ if recent_section:
518
+ welcome += recent_section
519
+ self.query_one("#slide-content", Markdown).update(welcome)
520
+ status = self.query_one("#status-bar", StatusBar)
521
+ status.current = 0
522
+ status.total = 1
523
+
524
+ def _update_display(self) -> None:
525
+ """Update the slide display."""
526
+ if not self.presentation or not self.presentation.slides:
527
+ return
528
+
529
+ slide = self.presentation.slides[self.current_slide]
530
+ image_widget = self.query_one("#slide-image", ImageDisplay)
531
+ image_container = self.query_one("#image-container")
532
+ slide_container = self.query_one("#slide-container")
533
+ horizontal_container = self.query_one("#slide-horizontal", Horizontal)
534
+
535
+ # Reset layout classes
536
+ image_container.remove_class(
537
+ "visible", "layout-left", "layout-right", "layout-inline"
538
+ )
539
+
540
+ # Handle images - render using colored half-block characters
541
+ if slide.images:
542
+ # Use first image (most common case)
543
+ first_image = slide.images[0]
544
+ resolved_path = resolve_image_path(first_image.path, self.presentation_path)
545
+
546
+ if resolved_path:
547
+ image_widget.set_image(
548
+ resolved_path,
549
+ width=first_image.width,
550
+ height=first_image.height,
551
+ )
552
+ image_container.add_class("visible")
553
+
554
+ # Apply layout based on MARP directive
555
+ layout = first_image.layout
556
+ if layout == "left":
557
+ image_container.add_class("layout-left")
558
+ # Ensure image is before text
559
+ horizontal_container.move_child(
560
+ image_container, before=slide_container
561
+ )
562
+ elif layout == "right":
563
+ image_container.add_class("layout-right")
564
+ # Move image after text
565
+ horizontal_container.move_child(
566
+ image_container, after=slide_container
567
+ )
568
+ elif layout == "inline":
569
+ image_container.add_class("layout-inline")
570
+ horizontal_container.move_child(
571
+ image_container, before=slide_container
572
+ )
573
+ elif layout in ("background", "fit"):
574
+ # Background/fit images: show image full width behind/above text
575
+ image_container.add_class("layout-inline")
576
+ horizontal_container.move_child(
577
+ image_container, before=slide_container
578
+ )
579
+
580
+ # Apply dynamic width if size_percent is specified
581
+ default_size = 50
582
+ has_custom_size = first_image.size_percent != default_size
583
+ if has_custom_size and layout in ("left", "right"):
584
+ image_container.styles.width = f"{first_image.size_percent}%"
585
+ else:
586
+ image_container.styles.width = None # Reset to CSS default
587
+ else:
588
+ image_widget.clear()
589
+
590
+ # Use cleaned content (bg images already removed by parser)
591
+ self.query_one("#slide-content", Markdown).update(slide.content.strip())
592
+
593
+ container = self.query_one("#slide-container", VerticalScroll)
594
+ container.scroll_home(animate=False)
595
+
596
+ self._update_progress_bar()
597
+ self._update_notes()
598
+
599
+ def _update_progress_bar(self) -> None:
600
+ """Update the progress bar."""
601
+ if not self.presentation:
602
+ return
603
+
604
+ status = self.query_one("#status-bar", StatusBar)
605
+ status.current = self.current_slide
606
+ status.total = self.presentation.total_slides
607
+
608
+ def _update_notes(self) -> None:
609
+ """Update the notes panel content."""
610
+ if not self.presentation or not self.presentation.slides:
611
+ return
612
+
613
+ slide = self.presentation.slides[self.current_slide]
614
+ notes_content = self.query_one("#notes-content", Markdown)
615
+
616
+ if slide.notes:
617
+ notes_content.update(slide.notes)
618
+ else:
619
+ notes_content.update("*No notes for this slide*")
620
+
621
+ def watch_current_slide(self, old_value: int, new_value: int) -> None:
622
+ """React to slide changes."""
623
+ self._update_display()
624
+ self._save_position()
625
+
626
+ def _save_position(self) -> None:
627
+ """Save current position to state."""
628
+ if self.presentation_path:
629
+ abs_path = str(self.presentation_path.absolute())
630
+ self.state.set_position(abs_path, self.current_slide)
631
+ save_state(self.state)
632
+
633
+ def watch_notes_visible(self, visible: bool) -> None:
634
+ """React to notes panel visibility changes."""
635
+ notes_panel = self.query_one("#notes-panel")
636
+ if visible:
637
+ notes_panel.add_class("visible")
638
+ else:
639
+ notes_panel.remove_class("visible")
640
+
641
+ def action_next_slide(self) -> None:
642
+ """Go to the next slide."""
643
+ if (
644
+ self.presentation
645
+ and self.current_slide < self.presentation.total_slides - 1
646
+ ):
647
+ self.current_slide += 1
648
+
649
+ def action_prev_slide(self) -> None:
650
+ """Go to the previous slide."""
651
+ if self.current_slide > 0:
652
+ self.current_slide -= 1
653
+
654
+ def action_first_slide(self) -> None:
655
+ """Go to the first slide."""
656
+ self.current_slide = 0
657
+
658
+ def action_last_slide(self) -> None:
659
+ """Go to the last slide."""
660
+ if self.presentation:
661
+ self.current_slide = self.presentation.total_slides - 1
662
+
663
+ def action_show_overview(self) -> None:
664
+ """Show the slide overview grid."""
665
+ if not self.presentation:
666
+ return
667
+
668
+ def handle_overview_result(slide_index: int | None) -> None:
669
+ if slide_index is not None:
670
+ self.current_slide = slide_index
671
+
672
+ self.push_screen(
673
+ SlideOverviewScreen(self.presentation, self.current_slide),
674
+ handle_overview_result,
675
+ )
676
+
677
+ def action_goto_slide(self) -> None:
678
+ """Show go-to-slide dialog."""
679
+ if not self.presentation:
680
+ return
681
+
682
+ def handle_goto_result(slide_index: int | None) -> None:
683
+ if slide_index is not None:
684
+ self.current_slide = slide_index
685
+
686
+ self.push_screen(
687
+ GotoSlideScreen(self.presentation.total_slides),
688
+ handle_goto_result,
689
+ )
690
+
691
+ def action_search(self) -> None:
692
+ """Show slide search dialog."""
693
+ if not self.presentation:
694
+ return
695
+
696
+ def handle_search_result(slide_index: int | None) -> None:
697
+ if slide_index is not None:
698
+ self.current_slide = slide_index
699
+
700
+ self.push_screen(
701
+ SlideSearchScreen(self.presentation),
702
+ handle_search_result,
703
+ )
704
+
705
+ def action_show_toc(self) -> None:
706
+ """Show table of contents."""
707
+ if not self.presentation:
708
+ return
709
+
710
+ def handle_toc_result(slide_index: int | None) -> None:
711
+ if slide_index is not None:
712
+ self.current_slide = slide_index
713
+
714
+ self.push_screen(
715
+ TableOfContentsScreen(self.presentation, self.current_slide),
716
+ handle_toc_result,
717
+ )
718
+
719
+ def action_toggle_notes(self) -> None:
720
+ """Toggle the notes panel visibility."""
721
+ self.notes_visible = not self.notes_visible
722
+
723
+ def action_toggle_clock(self) -> None:
724
+ """Cycle through clock display modes."""
725
+ self.query_one("#status-bar", StatusBar).toggle_clock()
726
+
727
+ def action_cycle_theme(self) -> None:
728
+ """Cycle through available themes."""
729
+ self.app_theme = get_next_theme(self.app_theme)
730
+
731
+ def action_show_help(self) -> None:
732
+ """Show the help screen."""
733
+ self.push_screen(HelpScreen())
734
+
735
+ def watch_app_theme(self, theme_name: str) -> None:
736
+ """Apply theme when it changes."""
737
+ # Only apply to widgets after mount (watcher fires during init)
738
+ if not self.is_mounted:
739
+ return
740
+ self._apply_theme(theme_name)
741
+ self.notify(f"Theme: {theme_name}", timeout=1)
742
+
743
+ def _apply_theme(self, theme_name: str) -> None:
744
+ """Apply theme colors to all widgets."""
745
+ theme = get_theme(theme_name)
746
+
747
+ # Use Textual's dark mode as a base
748
+ self.dark = theme_name != "light"
749
+
750
+ # Apply theme colors via CSS variables
751
+ self.set_class(theme_name in ("light",), "light-theme")
752
+
753
+ # Update the app's design with theme colors
754
+ self.styles.background = theme.background
755
+
756
+ # Apply to slide container
757
+ slide_container = self.query_one("#slide-container", VerticalScroll)
758
+ slide_container.styles.background = theme.surface
759
+
760
+ # Apply to status bar
761
+ status_bar = self.query_one("#status-bar", StatusBar)
762
+ status_bar.styles.background = theme.primary
763
+ status_bar.styles.color = theme.text
764
+
765
+ # Apply to notes panel
766
+ notes_panel = self.query_one("#notes-panel")
767
+ notes_panel.styles.background = theme.surface
768
+ notes_panel.styles.border_left = ("solid", theme.primary)
769
+
770
+ notes_title = self.query_one("#notes-title", Static)
771
+ notes_title.styles.color = theme.primary
772
+
773
+ def action_blackout(self) -> None:
774
+ """Show blackout screen."""
775
+ self.push_screen(BlackoutScreen(white=False))
776
+
777
+ def action_whiteout(self) -> None:
778
+ """Show whiteout screen."""
779
+ self.push_screen(BlackoutScreen(white=True))
780
+
781
+ def action_reload(self) -> None:
782
+ """Manually reload the presentation."""
783
+ if self.presentation_path:
784
+ self._reload_presentation()
785
+ else:
786
+ self.notify("No presentation file to reload", severity="warning")
787
+
788
+ def action_view_image(self) -> None:
789
+ """View current slide's image in native quality (suspend mode)."""
790
+ if not self.presentation or not self.presentation.slides:
791
+ return
792
+
793
+ slide = self.presentation.slides[self.current_slide]
794
+ if not slide.images:
795
+ self.notify("No image on this slide", timeout=2)
796
+ return
797
+
798
+ # Get the resolved image path
799
+ first_image = slide.images[0]
800
+ resolved_path = resolve_image_path(first_image.path, self.presentation_path)
801
+ if not resolved_path or not resolved_path.exists():
802
+ self.notify("Image not found", severity="warning")
803
+ return
804
+
805
+ # View image in suspend mode using native protocol
806
+ self._view_image_native(resolved_path)
807
+
808
+ def _view_image_native(self, image_path: Path) -> None:
809
+ """Display image using native terminal protocol in suspend mode."""
810
+ capability = detect_image_capability()
811
+
812
+ with self.suspend():
813
+ # Clear screen
814
+ sys.stdout.write("\x1b[2J\x1b[H")
815
+
816
+ # Get terminal size
817
+ try:
818
+ size = os.get_terminal_size()
819
+ width, height = size.columns, size.lines - 2
820
+ except OSError:
821
+ width, height = 80, 24
822
+
823
+ # Show image based on capability
824
+ if capability == ImageCapability.ITERM:
825
+ self._show_iterm_image(image_path, width, height)
826
+ elif capability == ImageCapability.KITTY:
827
+ self._show_kitty_image(image_path, width, height)
828
+ else:
829
+ # Fall back to chafa or half-block in suspend mode
830
+ self._show_fallback_image(image_path, width, height)
831
+
832
+ # Show instructions
833
+ print(f"\n\nImage: {image_path.name}")
834
+ print("Press any key to return...")
835
+
836
+ # Wait for keypress
837
+ fd = sys.stdin.fileno()
838
+ old_settings = termios.tcgetattr(fd)
839
+ try:
840
+ tty.setraw(fd)
841
+ sys.stdin.read(1)
842
+ finally:
843
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
844
+
845
+ def _show_iterm_image(self, path: Path, width: int, height: int) -> None:
846
+ """Show image using iTerm2 protocol."""
847
+ with open(path, "rb") as f:
848
+ data = base64.b64encode(f.read()).decode("ascii")
849
+
850
+ name_b64 = base64.b64encode(path.name.encode()).decode("ascii")
851
+ size = path.stat().st_size
852
+
853
+ params = (
854
+ f"name={name_b64};size={size};width={width};height={height};"
855
+ f"inline=1;preserveAspectRatio=1"
856
+ )
857
+ sys.stdout.write(f"\x1b]1337;File={params}:{data}\x07")
858
+ sys.stdout.flush()
859
+
860
+ def _show_kitty_image(self, path: Path, width: int, height: int) -> None:
861
+ """Show image using Kitty protocol."""
862
+ with open(path, "rb") as f:
863
+ data = base64.b64encode(f.read()).decode("ascii")
864
+
865
+ # Kitty protocol with chunked transmission
866
+ chunk_size = 4096
867
+ chunks = [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]
868
+
869
+ for i, chunk in enumerate(chunks):
870
+ is_last = i == len(chunks) - 1
871
+ m = 0 if is_last else 1
872
+ if i == 0:
873
+ sys.stdout.write(
874
+ f"\x1b_Ga=T,f=100,c={width},r={height},m={m};{chunk}\x1b\\"
875
+ )
876
+ else:
877
+ sys.stdout.write(f"\x1b_Gm={m};{chunk}\x1b\\")
878
+
879
+ sys.stdout.flush()
880
+
881
+ def _show_fallback_image(self, path: Path, width: int, height: int) -> None:
882
+ """Show image using chafa or half-block."""
883
+ if chafa_available():
884
+ result = render_with_chafa(path, width, height)
885
+ if result:
886
+ print(result)
887
+ return
888
+
889
+ renderer = HalfBlockRenderer()
890
+ print(renderer.render(path, width, height))
891
+
892
+ def action_edit_slide(self) -> None:
893
+ """Edit the current slide in an external editor."""
894
+ if not self.presentation or not self.presentation.source_path:
895
+ self.notify("No presentation file to edit", severity="warning")
896
+ return
897
+
898
+ slide = self.presentation.slides[self.current_slide]
899
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
900
+
901
+ with tempfile.NamedTemporaryFile(
902
+ mode="w",
903
+ suffix=".md",
904
+ prefix=f"slide_{self.current_slide + 1}_",
905
+ delete=False,
906
+ ) as f:
907
+ f.write(slide.raw_content)
908
+ temp_path = f.name
909
+
910
+ try:
911
+ with self.suspend():
912
+ subprocess.run([editor, temp_path], check=True)
913
+
914
+ edited_content = Path(temp_path).read_text()
915
+
916
+ if edited_content != slide.raw_content:
917
+ self.presentation.update_slide(self.current_slide, edited_content)
918
+ self.notify("Slide saved", timeout=2)
919
+ self._reload_presentation()
920
+ else:
921
+ self.notify("No changes made", timeout=2)
922
+
923
+ except subprocess.CalledProcessError:
924
+ self.notify("Editor exited with error", severity="error")
925
+ except Exception as e:
926
+ self.notify(f"Edit failed: {e}", severity="error")
927
+ finally:
928
+ with contextlib.suppress(OSError):
929
+ os.unlink(temp_path)
930
+
931
+
932
+ def run_app(
933
+ presentation_path: str | Path | None = None,
934
+ *,
935
+ watch: bool | None = None,
936
+ config: Config | None = None,
937
+ ) -> None:
938
+ """Run the Prezo application.
939
+
940
+ Args:
941
+ presentation_path: Path to the presentation file.
942
+ watch: Whether to watch for file changes. Uses config default if None.
943
+ config: Optional config override. Uses global config if None.
944
+
945
+ """
946
+ app = PrezoApp(presentation_path, watch=watch, config=config)
947
+ app.run()