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/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
+ - ![alt](path) - inline image
229
+ - ![bg](path) - background image
230
+ - ![bg left](path) - image on left side
231
+ - ![bg right](path) - image on right side
232
+ - ![bg left:40%](path) - image on left with specific size
233
+ - ![bg fit](path) - 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: ![...](path)
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
+ - ![bg](path) - background
294
+ - ![bg left](path) - left layout
295
+ - ![bg right:40%](path) - right layout with size
296
+ - ![w:50](path) or ![width:50](path) - width in characters
297
+ - ![h:20](path) or ![height:20](path) - height in characters
298
+ - Combined: ![bg left w:40 h:20](path)
299
+
300
+ Args:
301
+ alt_text: The alt text from ![alt](path)
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)