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/__init__.py +216 -0
- prezo/app.py +947 -0
- prezo/config.py +247 -0
- prezo/export.py +833 -0
- prezo/images/__init__.py +14 -0
- prezo/images/ascii.py +240 -0
- prezo/images/base.py +111 -0
- prezo/images/chafa.py +137 -0
- prezo/images/iterm.py +126 -0
- prezo/images/kitty.py +360 -0
- prezo/images/overlay.py +291 -0
- prezo/images/processor.py +139 -0
- prezo/images/sixel.py +180 -0
- prezo/parser.py +456 -0
- prezo/screens/__init__.py +21 -0
- prezo/screens/base.py +65 -0
- prezo/screens/blackout.py +60 -0
- prezo/screens/goto.py +99 -0
- prezo/screens/help.py +140 -0
- prezo/screens/overview.py +184 -0
- prezo/screens/search.py +252 -0
- prezo/screens/toc.py +254 -0
- prezo/terminal.py +147 -0
- prezo/themes.py +129 -0
- prezo/widgets/__init__.py +9 -0
- prezo/widgets/image_display.py +117 -0
- prezo/widgets/slide_button.py +72 -0
- prezo/widgets/status_bar.py +240 -0
- prezo-0.3.1.dist-info/METADATA +194 -0
- prezo-0.3.1.dist-info/RECORD +32 -0
- prezo-0.3.1.dist-info/WHEEL +4 -0
- prezo-0.3.1.dist-info/entry_points.txt +3 -0
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()
|