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/parser.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Parse MARP/Deckset-style Markdown presentations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import frontmatter
|
|
12
|
+
|
|
13
|
+
# -----------------------------------------------------------------------------
|
|
14
|
+
# Data Types (Nouns)
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ImageRef:
|
|
20
|
+
"""Reference to an image in a slide."""
|
|
21
|
+
|
|
22
|
+
alt: str # Alt text
|
|
23
|
+
path: str # Path as written in markdown
|
|
24
|
+
start: int # Start position in content
|
|
25
|
+
end: int # End position in content
|
|
26
|
+
# MARP-style layout directives
|
|
27
|
+
layout: str = "inline" # "inline", "left", "right", "background", "fit"
|
|
28
|
+
size_percent: int = 50 # Size percentage for left/right layouts
|
|
29
|
+
# MARP-style size directives (in characters for TUI, or percentage)
|
|
30
|
+
width: int | None = None # Width in characters (None = auto)
|
|
31
|
+
height: int | None = None # Height in characters (None = auto)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Slide:
|
|
36
|
+
"""A single slide in the presentation."""
|
|
37
|
+
|
|
38
|
+
content: str # Cleaned content for display
|
|
39
|
+
index: int
|
|
40
|
+
raw_content: str = "" # Original content for editing
|
|
41
|
+
notes: str = ""
|
|
42
|
+
images: list[ImageRef] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PresentationConfig:
|
|
47
|
+
"""Prezo-specific configuration from presentation directives."""
|
|
48
|
+
|
|
49
|
+
theme: str | None = None
|
|
50
|
+
show_clock: bool | None = None
|
|
51
|
+
show_elapsed: bool | None = None
|
|
52
|
+
countdown_minutes: int | None = None
|
|
53
|
+
image_mode: str | None = None
|
|
54
|
+
|
|
55
|
+
def merge_to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert non-None values to a config dict for merging."""
|
|
57
|
+
result: dict[str, Any] = {}
|
|
58
|
+
if self.theme is not None:
|
|
59
|
+
result.setdefault("display", {})["theme"] = self.theme
|
|
60
|
+
if self.show_clock is not None:
|
|
61
|
+
result.setdefault("timer", {})["show_clock"] = self.show_clock
|
|
62
|
+
if self.show_elapsed is not None:
|
|
63
|
+
result.setdefault("timer", {})["show_elapsed"] = self.show_elapsed
|
|
64
|
+
if self.countdown_minutes is not None:
|
|
65
|
+
result.setdefault("timer", {})["countdown_minutes"] = self.countdown_minutes
|
|
66
|
+
if self.image_mode is not None:
|
|
67
|
+
result.setdefault("images", {})["mode"] = self.image_mode
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Presentation:
|
|
73
|
+
"""A parsed presentation with metadata and slides."""
|
|
74
|
+
|
|
75
|
+
slides: list[Slide] = field(default_factory=list)
|
|
76
|
+
title: str = ""
|
|
77
|
+
theme: str = "default"
|
|
78
|
+
metadata: dict = field(default_factory=dict)
|
|
79
|
+
source_path: Path | None = None
|
|
80
|
+
directives: PresentationConfig = field(default_factory=PresentationConfig)
|
|
81
|
+
_raw_frontmatter: str = "" # Original frontmatter text for reconstruction
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def total_slides(self) -> int:
|
|
85
|
+
"""Return the total number of slides in the presentation."""
|
|
86
|
+
return len(self.slides)
|
|
87
|
+
|
|
88
|
+
def update_slide(self, index: int, new_content: str) -> None:
|
|
89
|
+
"""Update a slide's content and save to source file."""
|
|
90
|
+
if not self.source_path:
|
|
91
|
+
msg = "Cannot save: no source file path"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
if not 0 <= index < len(self.slides):
|
|
94
|
+
msg = f"Invalid slide index: {index}"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
|
|
97
|
+
slide_content, _notes = extract_notes(new_content)
|
|
98
|
+
self.slides[index].raw_content = new_content
|
|
99
|
+
self.slides[index].content = clean_marp_directives(slide_content).strip()
|
|
100
|
+
|
|
101
|
+
save_presentation(self)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# -----------------------------------------------------------------------------
|
|
105
|
+
# Main Public API (Verbs)
|
|
106
|
+
# -----------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_presentation(source: str | Path) -> Presentation:
|
|
110
|
+
"""Parse a Markdown presentation from a file path or string.
|
|
111
|
+
|
|
112
|
+
Supports MARP/Deckset conventions:
|
|
113
|
+
- YAML frontmatter for metadata
|
|
114
|
+
- `---` to separate slides
|
|
115
|
+
- `???` or `<!-- notes -->` for presenter notes (optional)
|
|
116
|
+
"""
|
|
117
|
+
source_path, text = _read_source(source)
|
|
118
|
+
return _parse_content(text, source_path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_presentation(presentation: Presentation) -> None:
|
|
122
|
+
"""Save presentation to its source file."""
|
|
123
|
+
if not presentation.source_path:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
content = _reconstruct_content(presentation)
|
|
127
|
+
presentation.source_path.write_text(content)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -----------------------------------------------------------------------------
|
|
131
|
+
# Pure Parsing Functions (Functional Core)
|
|
132
|
+
# -----------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def split_slides(content: str) -> list[str]:
|
|
136
|
+
"""Split content by slide separators (---).
|
|
137
|
+
|
|
138
|
+
Handles MARP/Deckset convention where --- on its own line separates slides.
|
|
139
|
+
"""
|
|
140
|
+
parts = re.split(r"\n---\s*\n", content)
|
|
141
|
+
slides = [p for p in parts if p.strip()]
|
|
142
|
+
return slides if slides else [""]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_notes(content: str) -> tuple[str, str]:
|
|
146
|
+
"""Extract presenter notes from slide content.
|
|
147
|
+
|
|
148
|
+
Supports:
|
|
149
|
+
- `???` separator (Remark.js style)
|
|
150
|
+
- `<!-- notes: ... -->` HTML comments
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Tuple of (content_without_notes, notes)
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
if "\n???" in content:
|
|
157
|
+
parts = content.split("\n???", 1)
|
|
158
|
+
return parts[0], parts[1] if len(parts) > 1 else ""
|
|
159
|
+
|
|
160
|
+
match = re.search(
|
|
161
|
+
r"<!--\s*notes?:\s*(.*?)\s*-->",
|
|
162
|
+
content,
|
|
163
|
+
re.DOTALL | re.IGNORECASE,
|
|
164
|
+
)
|
|
165
|
+
if match:
|
|
166
|
+
notes = match.group(1)
|
|
167
|
+
content = content[: match.start()] + content[match.end() :]
|
|
168
|
+
return content, notes
|
|
169
|
+
|
|
170
|
+
return content, ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def extract_prezo_directives(content: str) -> PresentationConfig:
|
|
174
|
+
"""Extract Prezo-specific directives from presentation content.
|
|
175
|
+
|
|
176
|
+
Looks for HTML comment blocks in the format:
|
|
177
|
+
<!-- prezo
|
|
178
|
+
theme: dark
|
|
179
|
+
show_clock: true
|
|
180
|
+
countdown_minutes: 45
|
|
181
|
+
-->
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
PresentationConfig with parsed directive values.
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
config = PresentationConfig()
|
|
188
|
+
|
|
189
|
+
# Look for prezo directive block
|
|
190
|
+
pattern = r"<!--\s*prezo\s+(.*?)-->"
|
|
191
|
+
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
|
|
192
|
+
|
|
193
|
+
if not match:
|
|
194
|
+
return config
|
|
195
|
+
|
|
196
|
+
directive_text = match.group(1)
|
|
197
|
+
|
|
198
|
+
# Parse key: value pairs
|
|
199
|
+
for line in directive_text.strip().split("\n"):
|
|
200
|
+
line = line.strip()
|
|
201
|
+
if not line or ":" not in line:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
key, _, value = line.partition(":")
|
|
205
|
+
key = key.strip().lower()
|
|
206
|
+
value = value.strip()
|
|
207
|
+
|
|
208
|
+
# Parse known directives
|
|
209
|
+
if key == "theme":
|
|
210
|
+
config.theme = value
|
|
211
|
+
elif key in ("show_clock", "showclock"):
|
|
212
|
+
config.show_clock = value.lower() in ("true", "1", "yes", "on")
|
|
213
|
+
elif key in ("show_elapsed", "showelapsed"):
|
|
214
|
+
config.show_elapsed = value.lower() in ("true", "1", "yes", "on")
|
|
215
|
+
elif key in ("countdown_minutes", "countdown", "countdownminutes"):
|
|
216
|
+
with contextlib.suppress(ValueError):
|
|
217
|
+
config.countdown_minutes = int(value)
|
|
218
|
+
elif key in ("image_mode", "imagemode", "images"):
|
|
219
|
+
config.image_mode = value
|
|
220
|
+
|
|
221
|
+
return config
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def extract_images(content: str) -> list[ImageRef]:
|
|
225
|
+
"""Extract markdown image references from content.
|
|
226
|
+
|
|
227
|
+
Handles both standard markdown images and MARP background images:
|
|
228
|
+
-  - inline image
|
|
229
|
+
-  - background image
|
|
230
|
+
-  - image on left side
|
|
231
|
+
-  - image on right side
|
|
232
|
+
-  - image on left with specific size
|
|
233
|
+
-  - fit image to container
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
content: Slide content to search.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of ImageRef objects for each image found.
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
images = []
|
|
243
|
+
|
|
244
|
+
# Match all markdown images: 
|
|
245
|
+
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
|
|
246
|
+
|
|
247
|
+
for match in re.finditer(pattern, content):
|
|
248
|
+
alt_text = match.group(1)
|
|
249
|
+
path = match.group(2)
|
|
250
|
+
|
|
251
|
+
# Parse MARP directives from alt text
|
|
252
|
+
directives = _parse_marp_image_directive(alt_text)
|
|
253
|
+
|
|
254
|
+
# Extract clean alt text (remove bg directives and size specs)
|
|
255
|
+
clean_alt = re.sub(r"^bg\s*", "", alt_text).strip()
|
|
256
|
+
layout_pattern = r"^(left|right|fit|contain|cover)(\s*:\s*\d+%)?"
|
|
257
|
+
clean_alt = re.sub(layout_pattern, "", clean_alt).strip()
|
|
258
|
+
# Remove size directives from alt text
|
|
259
|
+
clean_alt = re.sub(
|
|
260
|
+
r"(?:^|\s)(?:w|width|h|height)\s*:\s*\d+", "", clean_alt
|
|
261
|
+
).strip()
|
|
262
|
+
|
|
263
|
+
images.append(
|
|
264
|
+
ImageRef(
|
|
265
|
+
alt=clean_alt,
|
|
266
|
+
path=path,
|
|
267
|
+
start=match.start(),
|
|
268
|
+
end=match.end(),
|
|
269
|
+
layout=directives.layout,
|
|
270
|
+
size_percent=directives.size_percent,
|
|
271
|
+
width=directives.width,
|
|
272
|
+
height=directives.height,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return images
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class _ImageDirectives:
|
|
281
|
+
"""Parsed MARP image directives."""
|
|
282
|
+
|
|
283
|
+
layout: str = "inline"
|
|
284
|
+
size_percent: int = 50
|
|
285
|
+
width: int | None = None
|
|
286
|
+
height: int | None = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _parse_marp_image_directive(alt_text: str) -> _ImageDirectives:
|
|
290
|
+
"""Parse MARP image directive from alt text.
|
|
291
|
+
|
|
292
|
+
Supports:
|
|
293
|
+
-  - background
|
|
294
|
+
-  - left layout
|
|
295
|
+
-  - right layout with size
|
|
296
|
+
-  or  - width in characters
|
|
297
|
+
-  or  - height in characters
|
|
298
|
+
- Combined: 
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
alt_text: The alt text from 
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
_ImageDirectives with parsed values.
|
|
305
|
+
|
|
306
|
+
"""
|
|
307
|
+
result = _ImageDirectives()
|
|
308
|
+
alt_lower = alt_text.lower().strip()
|
|
309
|
+
|
|
310
|
+
# Parse width directive: w:N or width:N
|
|
311
|
+
width_match = re.search(r"(?:^|\s)(?:w|width)\s*:\s*(\d+)", alt_lower)
|
|
312
|
+
if width_match:
|
|
313
|
+
result.width = int(width_match.group(1))
|
|
314
|
+
|
|
315
|
+
# Parse height directive: h:N or height:N
|
|
316
|
+
height_match = re.search(r"(?:^|\s)(?:h|height)\s*:\s*(\d+)", alt_lower)
|
|
317
|
+
if height_match:
|
|
318
|
+
result.height = int(height_match.group(1))
|
|
319
|
+
|
|
320
|
+
# Not a background image - return with default inline layout
|
|
321
|
+
if not alt_lower.startswith("bg"):
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
# Parse the directive after "bg"
|
|
325
|
+
directive = alt_lower[2:].strip()
|
|
326
|
+
|
|
327
|
+
# Parse layout from directive
|
|
328
|
+
if not directive or directive.startswith(("w:", "width:", "h:", "height:")):
|
|
329
|
+
# Default background
|
|
330
|
+
result.layout = "background"
|
|
331
|
+
result.size_percent = 100
|
|
332
|
+
elif left_match := re.match(r"left(?:\s*:\s*(\d+)%)?", directive):
|
|
333
|
+
result.layout = "left"
|
|
334
|
+
result.size_percent = int(left_match.group(1)) if left_match.group(1) else 50
|
|
335
|
+
elif right_match := re.match(r"right(?:\s*:\s*(\d+)%)?", directive):
|
|
336
|
+
result.layout = "right"
|
|
337
|
+
result.size_percent = int(right_match.group(1)) if right_match.group(1) else 50
|
|
338
|
+
elif directive.startswith(("fit", "contain")):
|
|
339
|
+
result.layout = "fit"
|
|
340
|
+
result.size_percent = 100
|
|
341
|
+
else:
|
|
342
|
+
# Cover or unknown directive - treat as background
|
|
343
|
+
result.layout = "background"
|
|
344
|
+
result.size_percent = 100
|
|
345
|
+
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def clean_marp_directives(content: str) -> str:
|
|
350
|
+
"""Remove MARP-specific directives that don't render in TUI.
|
|
351
|
+
|
|
352
|
+
Cleans up:
|
|
353
|
+
- MARP HTML comments (<!-- _class: ... -->, <!-- _header: ... -->, etc.)
|
|
354
|
+
- MARP image directives (![bg ...])
|
|
355
|
+
- Empty HTML divs with only styling
|
|
356
|
+
"""
|
|
357
|
+
# Remove MARP directive comments
|
|
358
|
+
content = re.sub(r"<!--\s*_\w+:.*?-->\s*\n?", "", content)
|
|
359
|
+
|
|
360
|
+
# Remove MARP background image syntax (keep regular images)
|
|
361
|
+
content = re.sub(r"!\[bg[^\]]*\]\([^)]+\)\s*\n?", "", content)
|
|
362
|
+
|
|
363
|
+
# Remove empty divs with only style attributes
|
|
364
|
+
content = re.sub(r'<div[^>]*style="[^"]*"[^>]*>\s*</div>\s*\n?', "", content)
|
|
365
|
+
|
|
366
|
+
# Remove inline HTML divs (keep the content)
|
|
367
|
+
content = re.sub(r"<div[^>]*>\s*\n?", "", content)
|
|
368
|
+
content = re.sub(r"\s*</div>", "", content)
|
|
369
|
+
|
|
370
|
+
# Clean up multiple blank lines
|
|
371
|
+
return re.sub(r"\n{3,}", "\n\n", content)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# -----------------------------------------------------------------------------
|
|
375
|
+
# Private Implementation (Imperative Shell)
|
|
376
|
+
# -----------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _read_source(source: str | Path) -> tuple[Path | None, str]:
|
|
380
|
+
"""Read presentation source, handling both file paths and raw strings."""
|
|
381
|
+
if isinstance(source, Path) or (isinstance(source, str) and Path(source).exists()):
|
|
382
|
+
source_path = Path(source)
|
|
383
|
+
return source_path, source_path.read_text()
|
|
384
|
+
return None, source
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _parse_content(text: str, source_path: Path | None) -> Presentation:
|
|
388
|
+
"""Parse presentation content (pure logic, no I/O)."""
|
|
389
|
+
post = frontmatter.loads(text)
|
|
390
|
+
metadata = dict(post.metadata)
|
|
391
|
+
|
|
392
|
+
raw_frontmatter = _extract_raw_frontmatter(text, metadata)
|
|
393
|
+
title = str(metadata.get("title") or metadata.get("header", ""))
|
|
394
|
+
theme = str(metadata.get("theme", "default"))
|
|
395
|
+
|
|
396
|
+
# Extract Prezo-specific directives from content
|
|
397
|
+
directives = extract_prezo_directives(post.content)
|
|
398
|
+
|
|
399
|
+
# Override theme from directives if specified
|
|
400
|
+
if directives.theme:
|
|
401
|
+
theme = directives.theme
|
|
402
|
+
|
|
403
|
+
presentation = Presentation(
|
|
404
|
+
title=title,
|
|
405
|
+
theme=theme,
|
|
406
|
+
metadata=metadata,
|
|
407
|
+
source_path=source_path,
|
|
408
|
+
_raw_frontmatter=raw_frontmatter,
|
|
409
|
+
directives=directives,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
for i, raw_slide in enumerate(split_slides(post.content)):
|
|
413
|
+
slide_content, notes = extract_notes(raw_slide)
|
|
414
|
+
# Extract images BEFORE cleaning (clean_marp_directives removes bg images)
|
|
415
|
+
images = extract_images(slide_content)
|
|
416
|
+
cleaned_content = clean_marp_directives(slide_content).strip()
|
|
417
|
+
slide = Slide(
|
|
418
|
+
content=cleaned_content,
|
|
419
|
+
index=i,
|
|
420
|
+
raw_content=raw_slide,
|
|
421
|
+
notes=notes.strip(),
|
|
422
|
+
images=images,
|
|
423
|
+
)
|
|
424
|
+
presentation.slides.append(slide)
|
|
425
|
+
|
|
426
|
+
return presentation
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _extract_raw_frontmatter(text: str, metadata: dict) -> str:
|
|
430
|
+
"""Extract raw frontmatter text for reconstruction."""
|
|
431
|
+
if not metadata or not text.startswith("---"):
|
|
432
|
+
return ""
|
|
433
|
+
|
|
434
|
+
end_idx = text.find("\n---\n", 3)
|
|
435
|
+
if end_idx != -1:
|
|
436
|
+
return text[: end_idx + 5] # Include closing ---\n
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _reconstruct_content(presentation: Presentation) -> str:
|
|
441
|
+
"""Reconstruct presentation file content from slides."""
|
|
442
|
+
parts = []
|
|
443
|
+
|
|
444
|
+
if presentation._raw_frontmatter:
|
|
445
|
+
parts.append(presentation._raw_frontmatter)
|
|
446
|
+
|
|
447
|
+
for i, slide in enumerate(presentation.slides):
|
|
448
|
+
if i > 0:
|
|
449
|
+
parts.append("\n---\n")
|
|
450
|
+
parts.append(slide.raw_content)
|
|
451
|
+
|
|
452
|
+
content = "".join(parts)
|
|
453
|
+
if not content.endswith("\n"):
|
|
454
|
+
content += "\n"
|
|
455
|
+
|
|
456
|
+
return content
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Screen classes for Prezo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import ThemedModalScreen
|
|
6
|
+
from .blackout import BlackoutScreen
|
|
7
|
+
from .goto import GotoSlideScreen
|
|
8
|
+
from .help import HelpScreen
|
|
9
|
+
from .overview import SlideOverviewScreen
|
|
10
|
+
from .search import SlideSearchScreen
|
|
11
|
+
from .toc import TableOfContentsScreen
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BlackoutScreen",
|
|
15
|
+
"GotoSlideScreen",
|
|
16
|
+
"HelpScreen",
|
|
17
|
+
"SlideOverviewScreen",
|
|
18
|
+
"SlideSearchScreen",
|
|
19
|
+
"TableOfContentsScreen",
|
|
20
|
+
"ThemedModalScreen",
|
|
21
|
+
]
|
prezo/screens/base.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Base screen classes for Prezo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
|
|
9
|
+
from prezo.themes import Theme, get_theme
|
|
10
|
+
|
|
11
|
+
ResultType = TypeVar("ResultType")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ThemedModalScreen(ModalScreen, Generic[ResultType]):
|
|
15
|
+
"""Base modal screen that applies the current app theme."""
|
|
16
|
+
|
|
17
|
+
def on_mount(self) -> None:
|
|
18
|
+
"""Apply theme when mounted."""
|
|
19
|
+
self._apply_theme()
|
|
20
|
+
|
|
21
|
+
def _apply_theme(self) -> None:
|
|
22
|
+
"""Apply the current app theme to this modal."""
|
|
23
|
+
# Get the current theme from the app
|
|
24
|
+
theme_name = getattr(self.app, "app_theme", "dark")
|
|
25
|
+
theme = get_theme(theme_name)
|
|
26
|
+
|
|
27
|
+
# Apply theme to common container elements
|
|
28
|
+
self._apply_theme_to_containers(theme)
|
|
29
|
+
|
|
30
|
+
def _apply_theme_to_containers(self, theme: Theme) -> None:
|
|
31
|
+
"""Apply theme colors to container elements.
|
|
32
|
+
|
|
33
|
+
Override this in subclasses for custom theming.
|
|
34
|
+
"""
|
|
35
|
+
# Apply to container elements
|
|
36
|
+
for container_id in [
|
|
37
|
+
"help-container",
|
|
38
|
+
"overview-container",
|
|
39
|
+
"toc-container",
|
|
40
|
+
"search-container",
|
|
41
|
+
"goto-container",
|
|
42
|
+
]:
|
|
43
|
+
containers = self.query(f"#{container_id}")
|
|
44
|
+
for container in containers:
|
|
45
|
+
container.styles.background = theme.surface
|
|
46
|
+
container.styles.border = ("solid", theme.primary)
|
|
47
|
+
|
|
48
|
+
# Apply to title elements
|
|
49
|
+
for title_id in [
|
|
50
|
+
"help-title",
|
|
51
|
+
"overview-title",
|
|
52
|
+
"toc-title",
|
|
53
|
+
"search-title",
|
|
54
|
+
"goto-title",
|
|
55
|
+
]:
|
|
56
|
+
titles = self.query(f"#{title_id}")
|
|
57
|
+
for title in titles:
|
|
58
|
+
title.styles.background = theme.primary
|
|
59
|
+
title.styles.color = theme.text
|
|
60
|
+
|
|
61
|
+
# Apply to hint elements
|
|
62
|
+
for hint_id in ["toc-hint", "search-hint", "goto-hint"]:
|
|
63
|
+
hints = self.query(f"#{hint_id}")
|
|
64
|
+
for hint in hints:
|
|
65
|
+
hint.styles.color = theme.text_muted
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Blackout screen for Prezo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BlackoutScreen(ModalScreen[None]):
|
|
16
|
+
"""Modal screen for blacking out the display during presentation pauses."""
|
|
17
|
+
|
|
18
|
+
CSS = """
|
|
19
|
+
BlackoutScreen {
|
|
20
|
+
background: black;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#blackout-hint {
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
content-align: center middle;
|
|
27
|
+
color: #333;
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
32
|
+
Binding("escape", "dismiss", "Return", show=False),
|
|
33
|
+
Binding("b", "dismiss", "Return", show=False),
|
|
34
|
+
Binding("space", "dismiss", "Return", show=False),
|
|
35
|
+
Binding("enter", "dismiss", "Return", show=False),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(self, white: bool = False) -> None:
|
|
39
|
+
"""Initialize the blackout screen.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
white: If True, show white screen instead of black.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.white = white
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Compose the blackout screen layout."""
|
|
50
|
+
yield Static("Press any key to return", id="blackout-hint")
|
|
51
|
+
|
|
52
|
+
def on_mount(self) -> None:
|
|
53
|
+
"""Apply white theme if configured."""
|
|
54
|
+
if self.white:
|
|
55
|
+
self.styles.background = "white"
|
|
56
|
+
self.query_one("#blackout-hint").styles.color = "#ccc"
|
|
57
|
+
|
|
58
|
+
def on_key(self) -> None:
|
|
59
|
+
"""Dismiss the screen on any key press."""
|
|
60
|
+
self.dismiss(None)
|
prezo/screens/goto.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Go-to-slide screen for Prezo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.widgets import Input, Static
|
|
10
|
+
|
|
11
|
+
from .base import ThemedModalScreen
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GotoSlideScreen(ThemedModalScreen[int | None]):
|
|
18
|
+
"""Modal screen for jumping to a specific slide number."""
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
GotoSlideScreen {
|
|
22
|
+
align: center middle;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#goto-container {
|
|
26
|
+
width: 40;
|
|
27
|
+
height: auto;
|
|
28
|
+
background: $surface;
|
|
29
|
+
border: thick $primary;
|
|
30
|
+
padding: 1 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#goto-title {
|
|
34
|
+
width: 100%;
|
|
35
|
+
text-align: center;
|
|
36
|
+
text-style: bold;
|
|
37
|
+
margin-bottom: 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#goto-input {
|
|
41
|
+
width: 100%;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#goto-hint {
|
|
45
|
+
width: 100%;
|
|
46
|
+
text-align: center;
|
|
47
|
+
color: $text-muted;
|
|
48
|
+
margin-top: 1;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
53
|
+
Binding("escape", "cancel", "Cancel"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
def __init__(self, total_slides: int) -> None:
|
|
57
|
+
"""Initialize the go-to-slide screen.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
total_slides: Total number of slides in the presentation.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.total_slides = total_slides
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
"""Compose the go-to-slide dialog layout."""
|
|
68
|
+
with Vertical(id="goto-container"):
|
|
69
|
+
yield Static("Go to slide", id="goto-title")
|
|
70
|
+
yield Input(placeholder=f"1-{self.total_slides}", id="goto-input")
|
|
71
|
+
yield Static(f"Enter slide number (1-{self.total_slides})", id="goto-hint")
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
"""Focus the input field on mount."""
|
|
75
|
+
super().on_mount()
|
|
76
|
+
self.query_one("#goto-input", Input).focus()
|
|
77
|
+
|
|
78
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
79
|
+
"""Handle input submission and navigate to the specified slide."""
|
|
80
|
+
value = event.value.strip()
|
|
81
|
+
if not value:
|
|
82
|
+
self.dismiss(None)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
slide_num = int(value)
|
|
87
|
+
if 1 <= slide_num <= self.total_slides:
|
|
88
|
+
self.dismiss(slide_num - 1) # Convert to 0-indexed
|
|
89
|
+
else:
|
|
90
|
+
self.notify(
|
|
91
|
+
f"Invalid slide number. Enter 1-{self.total_slides}",
|
|
92
|
+
severity="error",
|
|
93
|
+
)
|
|
94
|
+
except ValueError:
|
|
95
|
+
self.notify("Please enter a valid number", severity="error")
|
|
96
|
+
|
|
97
|
+
def action_cancel(self) -> None:
|
|
98
|
+
"""Cancel and dismiss the dialog."""
|
|
99
|
+
self.dismiss(None)
|