prezo 2026.1.3__py3-none-any.whl → 2026.2.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 +35 -1
- prezo/app.py +303 -16
- prezo/config.py +2 -0
- prezo/export/svg.py +74 -71
- prezo/layout.py +96 -5
- prezo/parser.py +37 -0
- prezo/screens/help.py +4 -0
- prezo/widgets/status_bar.py +126 -12
- {prezo-2026.1.3.dist-info → prezo-2026.2.1.dist-info}/METADATA +6 -1
- {prezo-2026.1.3.dist-info → prezo-2026.2.1.dist-info}/RECORD +12 -12
- {prezo-2026.1.3.dist-info → prezo-2026.2.1.dist-info}/WHEEL +0 -0
- {prezo-2026.1.3.dist-info → prezo-2026.2.1.dist-info}/entry_points.txt +0 -0
prezo/__init__.py
CHANGED
|
@@ -78,6 +78,16 @@ def _validate_file(path: Path, must_exist: bool = True) -> Path:
|
|
|
78
78
|
return resolved
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def _get_version() -> str:
|
|
82
|
+
"""Get the package version from metadata."""
|
|
83
|
+
from importlib.metadata import version # noqa: PLC0415
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return version("prezo")
|
|
87
|
+
except Exception:
|
|
88
|
+
return "unknown"
|
|
89
|
+
|
|
90
|
+
|
|
81
91
|
def main() -> None:
|
|
82
92
|
"""Entry point for Prezo."""
|
|
83
93
|
parser = argparse.ArgumentParser(
|
|
@@ -85,6 +95,12 @@ def main() -> None:
|
|
|
85
95
|
description="TUI-based presentation tool for Markdown slides",
|
|
86
96
|
epilog="For more information, visit: https://github.com/abilian/prezo",
|
|
87
97
|
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"-v",
|
|
100
|
+
"--version",
|
|
101
|
+
action="version",
|
|
102
|
+
version=f"%(prog)s {_get_version()}",
|
|
103
|
+
)
|
|
88
104
|
parser.add_argument(
|
|
89
105
|
"file",
|
|
90
106
|
nargs="?",
|
|
@@ -159,6 +175,18 @@ def main() -> None:
|
|
|
159
175
|
choices=["auto", "kitty", "sixel", "iterm", "ascii", "none"],
|
|
160
176
|
help="Image rendering mode (auto, kitty, sixel, iterm, ascii, none)",
|
|
161
177
|
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"-I",
|
|
180
|
+
"--incremental",
|
|
181
|
+
action="store_true",
|
|
182
|
+
help="Display lists incrementally, one item at a time (like Pandoc)",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--time-budget",
|
|
186
|
+
metavar="MINUTES",
|
|
187
|
+
type=int,
|
|
188
|
+
help="Time budget in minutes for pacing indicator (shows if ahead/behind)",
|
|
189
|
+
)
|
|
162
190
|
|
|
163
191
|
args = parser.parse_args()
|
|
164
192
|
|
|
@@ -236,4 +264,10 @@ def main() -> None:
|
|
|
236
264
|
if args.file:
|
|
237
265
|
file_path = _validate_file(Path(args.file))
|
|
238
266
|
|
|
239
|
-
run_app(
|
|
267
|
+
run_app(
|
|
268
|
+
file_path,
|
|
269
|
+
watch=not args.no_watch,
|
|
270
|
+
config=config,
|
|
271
|
+
incremental=args.incremental,
|
|
272
|
+
time_budget=args.time_budget,
|
|
273
|
+
)
|
prezo/app.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import contextlib
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
import tempfile
|
|
@@ -66,6 +67,7 @@ prezo <presentation.md>
|
|
|
66
67
|
| **t** | Table of contents |
|
|
67
68
|
| **p** | Toggle notes |
|
|
68
69
|
| **c** | Toggle clock |
|
|
70
|
+
| **s** | Start/stop timer |
|
|
69
71
|
| **b** | Blackout screen |
|
|
70
72
|
| **e** | Edit current slide |
|
|
71
73
|
| **r** | Reload file |
|
|
@@ -109,6 +111,121 @@ def _format_recent_files(recent_files: list[str], max_files: int = 5) -> str:
|
|
|
109
111
|
return "\n".join(lines)
|
|
110
112
|
|
|
111
113
|
|
|
114
|
+
# -----------------------------------------------------------------------------
|
|
115
|
+
# Incremental List Helpers
|
|
116
|
+
# -----------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
# Pattern matching markdown list items (unordered and ordered)
|
|
119
|
+
_LIST_ITEM_PATTERN = re.compile(r"^(\s*)([-*+]|\d+\.)\s+")
|
|
120
|
+
|
|
121
|
+
# Pattern matching layout directive markers (:::)
|
|
122
|
+
_LAYOUT_MARKER_PATTERN = re.compile(r"^\s*:::")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def count_list_items(content: str) -> int:
|
|
126
|
+
"""Count the number of top-level list items in markdown content.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
content: Markdown content to analyze.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Number of top-level list items found.
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
count = 0
|
|
136
|
+
for line in content.split("\n"):
|
|
137
|
+
match = _LIST_ITEM_PATTERN.match(line)
|
|
138
|
+
if match:
|
|
139
|
+
# Only count top-level items (no leading whitespace)
|
|
140
|
+
indent = match.group(1)
|
|
141
|
+
if not indent:
|
|
142
|
+
count += 1
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Braille Pattern Blank - invisible character with width, behaves like text for layout
|
|
147
|
+
_INVISIBLE_CHAR = "\u2800"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _make_placeholder(text: str) -> str:
|
|
151
|
+
"""Create an invisible placeholder that matches the visual width of text.
|
|
152
|
+
|
|
153
|
+
Uses Braille Pattern Blank characters which are invisible but have
|
|
154
|
+
width and wrap like normal text.
|
|
155
|
+
"""
|
|
156
|
+
return _INVISIBLE_CHAR * len(text) if text else _INVISIBLE_CHAR
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def filter_list_items(content: str, max_items: int) -> str:
|
|
160
|
+
"""Filter content to show only the first N list items.
|
|
161
|
+
|
|
162
|
+
Preserves layout directive markers (:::) and other structural elements.
|
|
163
|
+
Hidden items are replaced with placeholder text of the same length
|
|
164
|
+
to maintain visual height when text wraps.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
content: Markdown content to filter.
|
|
168
|
+
max_items: Maximum number of top-level list items to show.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Filtered content with only the first N list items visible.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
if max_items < 0:
|
|
175
|
+
return content # Show all
|
|
176
|
+
|
|
177
|
+
lines = content.split("\n")
|
|
178
|
+
result_lines = []
|
|
179
|
+
item_count = 0
|
|
180
|
+
in_hidden_item = False
|
|
181
|
+
|
|
182
|
+
for line in lines:
|
|
183
|
+
# Always preserve layout markers (:::)
|
|
184
|
+
if _LAYOUT_MARKER_PATTERN.match(line):
|
|
185
|
+
result_lines.append(line)
|
|
186
|
+
# Reset hidden state when entering/exiting a block
|
|
187
|
+
in_hidden_item = False
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
match = _LIST_ITEM_PATTERN.match(line)
|
|
191
|
+
|
|
192
|
+
if match:
|
|
193
|
+
indent = match.group(1) # Leading whitespace
|
|
194
|
+
marker = match.group(2) # List marker (-, *, +, 1.)
|
|
195
|
+
text_start = match.end()
|
|
196
|
+
text = line[text_start:] # The actual text content
|
|
197
|
+
|
|
198
|
+
if len(indent) == 0:
|
|
199
|
+
# Top-level item
|
|
200
|
+
item_count += 1
|
|
201
|
+
if item_count <= max_items:
|
|
202
|
+
result_lines.append(line)
|
|
203
|
+
in_hidden_item = False
|
|
204
|
+
else:
|
|
205
|
+
# Replace with same-length placeholder
|
|
206
|
+
placeholder = _make_placeholder(text)
|
|
207
|
+
result_lines.append(f"{indent}{marker} {placeholder}")
|
|
208
|
+
in_hidden_item = True
|
|
209
|
+
elif in_hidden_item:
|
|
210
|
+
# Nested item under hidden parent - also hide
|
|
211
|
+
placeholder = _make_placeholder(text)
|
|
212
|
+
result_lines.append(f"{indent}{marker} {placeholder}")
|
|
213
|
+
else:
|
|
214
|
+
# Nested item - show if parent is visible
|
|
215
|
+
result_lines.append(line)
|
|
216
|
+
elif in_hidden_item:
|
|
217
|
+
# Content continuation of hidden item - preserve length
|
|
218
|
+
stripped = line.lstrip()
|
|
219
|
+
leading = line[: len(line) - len(stripped)]
|
|
220
|
+
placeholder = _make_placeholder(stripped)
|
|
221
|
+
result_lines.append(f"{leading}{placeholder}")
|
|
222
|
+
else:
|
|
223
|
+
# Non-list line (could be continuation or other content)
|
|
224
|
+
result_lines.append(line)
|
|
225
|
+
|
|
226
|
+
return "\n".join(result_lines)
|
|
227
|
+
|
|
228
|
+
|
|
112
229
|
class PrezoCommands(Provider):
|
|
113
230
|
"""Command provider for Prezo actions."""
|
|
114
231
|
|
|
@@ -142,6 +259,7 @@ class PrezoCommands(Provider):
|
|
|
142
259
|
("Search Slides", "search", "Search slides by content (/)"),
|
|
143
260
|
("Toggle Notes", "toggle_notes", "Show/hide presenter notes (p)"),
|
|
144
261
|
("Toggle Clock", "toggle_clock", "Cycle clock display mode (c)"),
|
|
262
|
+
("Start/Stop Timer", "toggle_timer", "Start or stop elapsed timer (S)"),
|
|
145
263
|
("Help", "show_help", "Show keyboard shortcuts (?)"),
|
|
146
264
|
]
|
|
147
265
|
)
|
|
@@ -330,6 +448,8 @@ class PrezoApp(App):
|
|
|
330
448
|
Binding("t", "show_toc", "TOC", show=True),
|
|
331
449
|
Binding("p", "toggle_notes", "Notes", show=True),
|
|
332
450
|
Binding("c", "toggle_clock", "Clock", show=False),
|
|
451
|
+
Binding("s", "toggle_timer", "Timer", show=False),
|
|
452
|
+
Binding("S", "toggle_timer", "Timer", show=False),
|
|
333
453
|
Binding("T", "cycle_theme", "Theme", show=False),
|
|
334
454
|
Binding("b", "blackout", "Blackout", show=False),
|
|
335
455
|
Binding("w", "whiteout", "Whiteout", show=False),
|
|
@@ -342,6 +462,7 @@ class PrezoApp(App):
|
|
|
342
462
|
current_slide: reactive[int] = reactive(0)
|
|
343
463
|
notes_visible: reactive[bool] = reactive(False)
|
|
344
464
|
app_theme: reactive[str] = reactive("dark")
|
|
465
|
+
reveal_index: reactive[int] = reactive(-1) # -1 = show all, 0+ = show up to index
|
|
345
466
|
|
|
346
467
|
TITLE = "Prezo"
|
|
347
468
|
|
|
@@ -351,6 +472,8 @@ class PrezoApp(App):
|
|
|
351
472
|
*,
|
|
352
473
|
watch: bool | None = None,
|
|
353
474
|
config: Config | None = None,
|
|
475
|
+
incremental: bool = False,
|
|
476
|
+
time_budget: int | None = None,
|
|
354
477
|
) -> None:
|
|
355
478
|
"""Initialize the Prezo application.
|
|
356
479
|
|
|
@@ -358,6 +481,8 @@ class PrezoApp(App):
|
|
|
358
481
|
presentation_path: Path to the Markdown presentation file.
|
|
359
482
|
watch: Whether to enable file watching for live reload.
|
|
360
483
|
config: Optional config override. Uses global config if None.
|
|
484
|
+
incremental: Whether to display lists incrementally (-I flag).
|
|
485
|
+
time_budget: Time budget in minutes for pacing indicator.
|
|
361
486
|
|
|
362
487
|
"""
|
|
363
488
|
super().__init__()
|
|
@@ -367,6 +492,12 @@ class PrezoApp(App):
|
|
|
367
492
|
self.presentation_path = Path(presentation_path) if presentation_path else None
|
|
368
493
|
self.presentation: Presentation | None = None
|
|
369
494
|
|
|
495
|
+
# Incremental lists: CLI flag overrides config
|
|
496
|
+
self.incremental_cli = incremental
|
|
497
|
+
|
|
498
|
+
# Time budget: CLI flag overrides config/presentation directives
|
|
499
|
+
self.time_budget_cli = time_budget
|
|
500
|
+
|
|
370
501
|
# Use config for watch if not explicitly set
|
|
371
502
|
if watch is None:
|
|
372
503
|
self.watch_enabled = self.config.behavior.auto_reload
|
|
@@ -436,11 +567,20 @@ class PrezoApp(App):
|
|
|
436
567
|
return
|
|
437
568
|
|
|
438
569
|
old_slide = self.current_slide
|
|
570
|
+
old_reveal = self.reveal_index
|
|
439
571
|
self.presentation = parse_presentation(self.presentation_path)
|
|
440
572
|
|
|
441
573
|
if old_slide >= self.presentation.total_slides:
|
|
442
|
-
|
|
574
|
+
target_slide = max(0, self.presentation.total_slides - 1)
|
|
575
|
+
self._init_reveal_for_slide(target_slide, show_all=False)
|
|
576
|
+
self.current_slide = target_slide
|
|
443
577
|
else:
|
|
578
|
+
# Preserve reveal position if still valid
|
|
579
|
+
list_count = self._get_list_count(old_slide)
|
|
580
|
+
if self._is_incremental_enabled(old_slide) and list_count > 0:
|
|
581
|
+
self.reveal_index = max(0, min(old_reveal, list_count - 1))
|
|
582
|
+
else:
|
|
583
|
+
self.reveal_index = -1
|
|
444
584
|
self._update_display()
|
|
445
585
|
|
|
446
586
|
self.notify("Presentation reloaded", timeout=2)
|
|
@@ -453,12 +593,16 @@ class PrezoApp(App):
|
|
|
453
593
|
# Restore last position or start at 0
|
|
454
594
|
abs_path = str(self.presentation_path.absolute())
|
|
455
595
|
last_pos = self.state.get_position(abs_path)
|
|
456
|
-
if last_pos < self.presentation.total_slides
|
|
457
|
-
|
|
596
|
+
target_slide = last_pos if last_pos < self.presentation.total_slides else 0
|
|
597
|
+
|
|
598
|
+
# Set the slide - the watcher will initialize reveal state
|
|
599
|
+
if self.current_slide == target_slide:
|
|
600
|
+
# Watcher won't fire, so initialize manually
|
|
601
|
+
self._init_reveal_for_slide(target_slide, show_all=False)
|
|
602
|
+
self._update_display()
|
|
458
603
|
else:
|
|
459
|
-
self.current_slide =
|
|
604
|
+
self.current_slide = target_slide
|
|
460
605
|
|
|
461
|
-
self._update_display()
|
|
462
606
|
self._update_progress_bar()
|
|
463
607
|
|
|
464
608
|
if self.presentation.title:
|
|
@@ -479,6 +623,81 @@ class PrezoApp(App):
|
|
|
479
623
|
self._apply_timer_config(status_bar)
|
|
480
624
|
status_bar.reset_timer()
|
|
481
625
|
|
|
626
|
+
def _is_incremental_enabled(self, slide_index: int | None = None) -> bool:
|
|
627
|
+
"""Check if incremental mode is enabled for a slide.
|
|
628
|
+
|
|
629
|
+
Priority: per-slide directive > CLI flag > config > presentation directive
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
slide_index: Slide index to check. Uses current_slide if None.
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
True if incremental lists should be enabled.
|
|
636
|
+
|
|
637
|
+
"""
|
|
638
|
+
if not self.presentation or not self.presentation.slides:
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
idx = slide_index if slide_index is not None else self.current_slide
|
|
642
|
+
if idx < 0 or idx >= len(self.presentation.slides):
|
|
643
|
+
return False
|
|
644
|
+
|
|
645
|
+
slide = self.presentation.slides[idx]
|
|
646
|
+
|
|
647
|
+
# Per-slide directive takes highest priority
|
|
648
|
+
if slide.incremental is not None:
|
|
649
|
+
return slide.incremental
|
|
650
|
+
|
|
651
|
+
# CLI flag overrides config and presentation directives
|
|
652
|
+
if self.incremental_cli:
|
|
653
|
+
return True
|
|
654
|
+
|
|
655
|
+
# Presentation directive (from <!-- prezo --> block)
|
|
656
|
+
if self.presentation.directives.incremental_lists is not None:
|
|
657
|
+
return self.presentation.directives.incremental_lists
|
|
658
|
+
|
|
659
|
+
# Fall back to config
|
|
660
|
+
return self.config.behavior.incremental_lists
|
|
661
|
+
|
|
662
|
+
def _get_list_count(self, slide_index: int | None = None) -> int:
|
|
663
|
+
"""Get the number of top-level list items in a slide.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
slide_index: Slide index to check. Uses current_slide if None.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Number of top-level list items.
|
|
670
|
+
|
|
671
|
+
"""
|
|
672
|
+
if not self.presentation or not self.presentation.slides:
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
idx = slide_index if slide_index is not None else self.current_slide
|
|
676
|
+
if idx < 0 or idx >= len(self.presentation.slides):
|
|
677
|
+
return 0
|
|
678
|
+
|
|
679
|
+
slide = self.presentation.slides[idx]
|
|
680
|
+
return count_list_items(slide.content)
|
|
681
|
+
|
|
682
|
+
def _init_reveal_for_slide(
|
|
683
|
+
self, slide_index: int, *, show_all: bool = False
|
|
684
|
+
) -> None:
|
|
685
|
+
"""Initialize reveal state for a specific slide.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
slide_index: The slide to initialize for.
|
|
689
|
+
show_all: If True, reveal all items. If False, start with first item.
|
|
690
|
+
|
|
691
|
+
"""
|
|
692
|
+
if self._is_incremental_enabled(slide_index):
|
|
693
|
+
list_count = self._get_list_count(slide_index)
|
|
694
|
+
if list_count > 0:
|
|
695
|
+
self.reveal_index = (list_count - 1) if show_all else 0
|
|
696
|
+
else:
|
|
697
|
+
self.reveal_index = -1 # No list items, show all content
|
|
698
|
+
else:
|
|
699
|
+
self.reveal_index = -1 # Incremental disabled, show all
|
|
700
|
+
|
|
482
701
|
def _apply_presentation_directives(self) -> None:
|
|
483
702
|
"""Apply presentation-specific directives on top of config."""
|
|
484
703
|
if not self.presentation:
|
|
@@ -496,6 +715,7 @@ class PrezoApp(App):
|
|
|
496
715
|
show_clock = self.config.timer.show_clock
|
|
497
716
|
show_elapsed = self.config.timer.show_elapsed
|
|
498
717
|
countdown = self.config.timer.countdown_minutes
|
|
718
|
+
time_budget = self.config.timer.time_budget_minutes
|
|
499
719
|
|
|
500
720
|
# Override with presentation directives if specified
|
|
501
721
|
if self.presentation:
|
|
@@ -506,12 +726,19 @@ class PrezoApp(App):
|
|
|
506
726
|
show_elapsed = directives.show_elapsed
|
|
507
727
|
if directives.countdown_minutes is not None:
|
|
508
728
|
countdown = directives.countdown_minutes
|
|
729
|
+
if directives.time_budget_minutes is not None:
|
|
730
|
+
time_budget = directives.time_budget_minutes
|
|
731
|
+
|
|
732
|
+
# CLI flag takes highest priority for time budget
|
|
733
|
+
if self.time_budget_cli is not None:
|
|
734
|
+
time_budget = self.time_budget_cli
|
|
509
735
|
|
|
510
736
|
# Apply to status bar
|
|
511
737
|
status_bar.show_clock = show_clock
|
|
512
738
|
status_bar.show_elapsed = show_elapsed
|
|
513
739
|
status_bar.countdown_minutes = countdown
|
|
514
740
|
status_bar.show_countdown = countdown > 0
|
|
741
|
+
status_bar.time_budget_minutes = time_budget
|
|
515
742
|
|
|
516
743
|
def _show_welcome(self) -> None:
|
|
517
744
|
"""Show welcome message when no presentation is loaded."""
|
|
@@ -583,9 +810,13 @@ class PrezoApp(App):
|
|
|
583
810
|
image_widget.clear()
|
|
584
811
|
|
|
585
812
|
# Use cleaned content (bg images already removed by parser)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
813
|
+
content = slide.content.strip()
|
|
814
|
+
|
|
815
|
+
# Apply incremental filtering if enabled
|
|
816
|
+
if self._is_incremental_enabled() and self.reveal_index >= 0:
|
|
817
|
+
content = filter_list_items(content, self.reveal_index + 1)
|
|
818
|
+
|
|
819
|
+
self.query_one("#slide-content", SlideContent).set_content(content)
|
|
589
820
|
|
|
590
821
|
container = self.query_one("#slide-container", VerticalScroll)
|
|
591
822
|
container.scroll_home(animate=False)
|
|
@@ -594,7 +825,7 @@ class PrezoApp(App):
|
|
|
594
825
|
self._update_notes()
|
|
595
826
|
|
|
596
827
|
def _update_progress_bar(self) -> None:
|
|
597
|
-
"""Update the progress bar."""
|
|
828
|
+
"""Update the progress bar and reveal indicator."""
|
|
598
829
|
if not self.presentation:
|
|
599
830
|
return
|
|
600
831
|
|
|
@@ -602,6 +833,19 @@ class PrezoApp(App):
|
|
|
602
833
|
status.current = self.current_slide
|
|
603
834
|
status.total = self.presentation.total_slides
|
|
604
835
|
|
|
836
|
+
# Update reveal indicator
|
|
837
|
+
if self._is_incremental_enabled():
|
|
838
|
+
list_count = self._get_list_count()
|
|
839
|
+
if list_count > 0 and self.reveal_index >= 0:
|
|
840
|
+
status.reveal_current = self.reveal_index
|
|
841
|
+
status.reveal_total = list_count
|
|
842
|
+
else:
|
|
843
|
+
status.reveal_current = -1
|
|
844
|
+
status.reveal_total = 0
|
|
845
|
+
else:
|
|
846
|
+
status.reveal_current = -1
|
|
847
|
+
status.reveal_total = 0
|
|
848
|
+
|
|
605
849
|
def _update_notes(self) -> None:
|
|
606
850
|
"""Update the notes panel content."""
|
|
607
851
|
if not self.presentation or not self.presentation.slides:
|
|
@@ -617,6 +861,9 @@ class PrezoApp(App):
|
|
|
617
861
|
|
|
618
862
|
def watch_current_slide(self, old_value: int, new_value: int) -> None:
|
|
619
863
|
"""React to slide changes."""
|
|
864
|
+
# Determine direction and initialize reveal state appropriately
|
|
865
|
+
going_back = new_value < old_value
|
|
866
|
+
self._init_reveal_for_slide(new_value, show_all=going_back)
|
|
620
867
|
self._update_display()
|
|
621
868
|
self._save_position()
|
|
622
869
|
|
|
@@ -636,15 +883,41 @@ class PrezoApp(App):
|
|
|
636
883
|
notes_panel.remove_class("visible")
|
|
637
884
|
|
|
638
885
|
def action_next_slide(self) -> None:
|
|
639
|
-
"""Go to the next slide."""
|
|
640
|
-
if
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
886
|
+
"""Go to the next slide or reveal next list item."""
|
|
887
|
+
if not self.presentation:
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
# Check if we should reveal next item instead of advancing slide
|
|
891
|
+
if self._is_incremental_enabled():
|
|
892
|
+
list_count = self._get_list_count()
|
|
893
|
+
if (
|
|
894
|
+
list_count > 0
|
|
895
|
+
and self.reveal_index >= 0
|
|
896
|
+
and self.reveal_index < list_count - 1
|
|
897
|
+
):
|
|
898
|
+
self.reveal_index += 1
|
|
899
|
+
self._update_display()
|
|
900
|
+
self._update_progress_bar()
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
# No more items to reveal, go to next slide
|
|
904
|
+
if self.current_slide < self.presentation.total_slides - 1:
|
|
905
|
+
# The watcher will initialize reveal_index for the new slide
|
|
644
906
|
self.current_slide += 1
|
|
645
907
|
|
|
646
908
|
def action_prev_slide(self) -> None:
|
|
647
|
-
"""Go to the previous slide."""
|
|
909
|
+
"""Go to the previous slide or hide last revealed item."""
|
|
910
|
+
if not self.presentation:
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
# Check if we should hide last item instead of going back
|
|
914
|
+
if self._is_incremental_enabled() and self.reveal_index > 0:
|
|
915
|
+
self.reveal_index -= 1
|
|
916
|
+
self._update_display()
|
|
917
|
+
self._update_progress_bar()
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
# Go to previous slide (watcher will show all items)
|
|
648
921
|
if self.current_slide > 0:
|
|
649
922
|
self.current_slide -= 1
|
|
650
923
|
|
|
@@ -721,6 +994,10 @@ class PrezoApp(App):
|
|
|
721
994
|
"""Cycle through clock display modes."""
|
|
722
995
|
self.query_one("#status-bar", StatusBar).toggle_clock()
|
|
723
996
|
|
|
997
|
+
def action_toggle_timer(self) -> None:
|
|
998
|
+
"""Start or stop the elapsed timer."""
|
|
999
|
+
self.query_one("#status-bar", StatusBar).toggle_timer()
|
|
1000
|
+
|
|
724
1001
|
def action_cycle_theme(self) -> None:
|
|
725
1002
|
"""Cycle through available themes."""
|
|
726
1003
|
self.app_theme = get_next_theme(self.app_theme)
|
|
@@ -931,6 +1208,8 @@ def run_app(
|
|
|
931
1208
|
*,
|
|
932
1209
|
watch: bool | None = None,
|
|
933
1210
|
config: Config | None = None,
|
|
1211
|
+
incremental: bool = False,
|
|
1212
|
+
time_budget: int | None = None,
|
|
934
1213
|
) -> None:
|
|
935
1214
|
"""Run the Prezo application.
|
|
936
1215
|
|
|
@@ -938,7 +1217,15 @@ def run_app(
|
|
|
938
1217
|
presentation_path: Path to the presentation file.
|
|
939
1218
|
watch: Whether to watch for file changes. Uses config default if None.
|
|
940
1219
|
config: Optional config override. Uses global config if None.
|
|
1220
|
+
incremental: Whether to display lists incrementally (-I flag).
|
|
1221
|
+
time_budget: Time budget in minutes for pacing indicator.
|
|
941
1222
|
|
|
942
1223
|
"""
|
|
943
|
-
app = PrezoApp(
|
|
1224
|
+
app = PrezoApp(
|
|
1225
|
+
presentation_path,
|
|
1226
|
+
watch=watch,
|
|
1227
|
+
config=config,
|
|
1228
|
+
incremental=incremental,
|
|
1229
|
+
time_budget=time_budget,
|
|
1230
|
+
)
|
|
944
1231
|
app.run()
|
prezo/config.py
CHANGED
|
@@ -61,6 +61,7 @@ class TimerConfig:
|
|
|
61
61
|
show_clock: bool = True
|
|
62
62
|
show_elapsed: bool = True
|
|
63
63
|
countdown_minutes: int = 0
|
|
64
|
+
time_budget_minutes: int = 0 # Total time budget for pacing indicator
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
@dataclass
|
|
@@ -69,6 +70,7 @@ class BehaviorConfig:
|
|
|
69
70
|
|
|
70
71
|
auto_reload: bool = True
|
|
71
72
|
reload_interval: float = 1.0
|
|
73
|
+
incremental_lists: bool = False # Display lists one item at a time
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
@dataclass
|
prezo/export/svg.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import io
|
|
6
|
+
import re
|
|
6
7
|
|
|
7
8
|
from rich.console import Console
|
|
8
9
|
from rich.markdown import Markdown
|
|
@@ -13,66 +14,62 @@ from rich.text import Text
|
|
|
13
14
|
from prezo.layout import has_layout_blocks, parse_layout, render_layout
|
|
14
15
|
from prezo.themes import get_theme
|
|
15
16
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
</g>
|
|
73
|
-
</g>
|
|
74
|
-
</svg>
|
|
75
|
-
"""
|
|
17
|
+
# Patterns for stripping window chrome from SVG
|
|
18
|
+
# Window border rect with rounded corners
|
|
19
|
+
_WINDOW_BORDER_PATTERN = re.compile(
|
|
20
|
+
r'<rect fill="[^"]*" stroke="rgba\(255,255,255,[^"]*\)" '
|
|
21
|
+
r'stroke-width="1" x="1" y="1" [^/]*/>'
|
|
22
|
+
)
|
|
23
|
+
# Title text element
|
|
24
|
+
_TITLE_TEXT_PATTERN = re.compile(r'<text class="[^"]*-title"[^>]*>[^<]*</text>')
|
|
25
|
+
# Traffic light buttons group
|
|
26
|
+
_TRAFFIC_LIGHTS_PATTERN = re.compile(
|
|
27
|
+
r'<g transform="translate\(26,22\)">\s*'
|
|
28
|
+
r"<circle[^/]*/>\s*<circle[^/]*/>\s*<circle[^/]*/>\s*</g>"
|
|
29
|
+
)
|
|
30
|
+
# Content group transform (to adjust offset)
|
|
31
|
+
_CONTENT_TRANSFORM_PATTERN = re.compile(
|
|
32
|
+
r'<g transform="translate\((\d+(?:\.\d+)?), (\d+(?:\.\d+)?)\)" '
|
|
33
|
+
r'clip-path="url\(#([^"]+)\)">'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strip_window_chrome(svg: str) -> str:
|
|
38
|
+
"""Remove window decorations from SVG for printing.
|
|
39
|
+
|
|
40
|
+
Removes:
|
|
41
|
+
- Window border (rounded rect with stroke)
|
|
42
|
+
- Title text
|
|
43
|
+
- Traffic light buttons (red/yellow/green circles)
|
|
44
|
+
|
|
45
|
+
Also adjusts the content position to start at origin.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
svg: SVG string with window chrome.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
SVG string without window chrome.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
# Remove window border
|
|
55
|
+
svg = _WINDOW_BORDER_PATTERN.sub("", svg)
|
|
56
|
+
|
|
57
|
+
# Remove title text
|
|
58
|
+
svg = _TITLE_TEXT_PATTERN.sub("", svg)
|
|
59
|
+
|
|
60
|
+
# Remove traffic lights
|
|
61
|
+
svg = _TRAFFIC_LIGHTS_PATTERN.sub("", svg)
|
|
62
|
+
|
|
63
|
+
# Adjust content transform to remove offset
|
|
64
|
+
# The content is typically at translate(9, 41) with chrome
|
|
65
|
+
# Move it to translate(0, 0) for clean output
|
|
66
|
+
def adjust_transform(match: re.Match) -> str:
|
|
67
|
+
clip_id = match.group(3)
|
|
68
|
+
return f'<g transform="translate(0, 0)" clip-path="url(#{clip_id})">'
|
|
69
|
+
|
|
70
|
+
svg = _CONTENT_TRANSFORM_PATTERN.sub(adjust_transform, svg)
|
|
71
|
+
|
|
72
|
+
return svg
|
|
76
73
|
|
|
77
74
|
|
|
78
75
|
def render_slide_to_svg(
|
|
@@ -121,8 +118,11 @@ def render_slide_to_svg(
|
|
|
121
118
|
else:
|
|
122
119
|
slide_content = Markdown(content)
|
|
123
120
|
|
|
124
|
-
# Create a panel with the slide content
|
|
125
|
-
|
|
121
|
+
# Create a panel with the slide content
|
|
122
|
+
# Panel takes full height minus status bar line
|
|
123
|
+
# Note: Rich's export_svg adds chrome which takes ~2 lines at top
|
|
124
|
+
# So we use height to maximize content area
|
|
125
|
+
panel_height = height
|
|
126
126
|
panel = Panel(
|
|
127
127
|
slide_content,
|
|
128
128
|
title=f"[{theme.text_muted}]Slide {slide_num + 1}/{total_slides}[/]",
|
|
@@ -134,8 +134,8 @@ def render_slide_to_svg(
|
|
|
134
134
|
height=panel_height,
|
|
135
135
|
)
|
|
136
136
|
|
|
137
|
-
# Print to the recording console with background
|
|
138
|
-
console.print(panel, style=base_style)
|
|
137
|
+
# Print to the recording console with background (no trailing newline)
|
|
138
|
+
console.print(panel, style=base_style, end="")
|
|
139
139
|
|
|
140
140
|
# Add status bar at the bottom
|
|
141
141
|
progress = (slide_num + 1) / total_slides
|
|
@@ -146,13 +146,10 @@ def render_slide_to_svg(
|
|
|
146
146
|
# Pad status bar to full width
|
|
147
147
|
status_text = status_text.ljust(width)
|
|
148
148
|
status = Text(status_text, style=Style(bgcolor=theme.primary, color=theme.text))
|
|
149
|
-
console.print(status, style=base_style)
|
|
149
|
+
console.print(status, style=base_style, end="")
|
|
150
150
|
|
|
151
|
-
# Export to SVG
|
|
152
|
-
|
|
153
|
-
svg = console.export_svg(title=f"Slide {slide_num + 1}")
|
|
154
|
-
else:
|
|
155
|
-
svg = console.export_svg(code_format=SVG_FORMAT_NO_CHROME)
|
|
151
|
+
# Export to SVG (always with Rich's default chrome first)
|
|
152
|
+
svg = console.export_svg(title=f"Slide {slide_num + 1}")
|
|
156
153
|
|
|
157
154
|
# Add emoji font fallbacks to font-family declarations
|
|
158
155
|
# Rich only specifies "Fira Code, monospace" which lacks emoji glyphs
|
|
@@ -164,7 +161,13 @@ def render_slide_to_svg(
|
|
|
164
161
|
# Add background color to SVG (Rich doesn't set it by default)
|
|
165
162
|
# Insert a rect element right after the opening svg tag
|
|
166
163
|
bg_rect = f'<rect width="100%" height="100%" fill="{theme.background}"/>'
|
|
167
|
-
|
|
164
|
+
svg = svg.replace(
|
|
168
165
|
'xmlns="http://www.w3.org/2000/svg">',
|
|
169
166
|
f'xmlns="http://www.w3.org/2000/svg">\n {bg_rect}',
|
|
170
167
|
)
|
|
168
|
+
|
|
169
|
+
# Remove window chrome if requested (for printing)
|
|
170
|
+
if not chrome:
|
|
171
|
+
svg = _strip_window_chrome(svg)
|
|
172
|
+
|
|
173
|
+
return svg
|
prezo/layout.py
CHANGED
|
@@ -538,11 +538,9 @@ class BoxRenderable:
|
|
|
538
538
|
self, console: Console, options: ConsoleOptions
|
|
539
539
|
) -> RenderResult:
|
|
540
540
|
"""Render content in a bordered panel."""
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
panel = Panel(md, title=self.title if self.title else None)
|
|
541
|
+
content = _render_box_content(self.content)
|
|
542
|
+
panel = Panel(content, title=self.title if self.title else None)
|
|
544
543
|
yield panel
|
|
545
|
-
yield Text("")
|
|
546
544
|
|
|
547
545
|
def __rich_measure__(
|
|
548
546
|
self, console: Console, options: ConsoleOptions
|
|
@@ -644,7 +642,10 @@ def render_layout(
|
|
|
644
642
|
"""
|
|
645
643
|
renderables: list[RenderableType] = []
|
|
646
644
|
|
|
647
|
-
for block in blocks:
|
|
645
|
+
for i, block in enumerate(blocks):
|
|
646
|
+
# Add spacing before box blocks (except the first one)
|
|
647
|
+
if block.type == "box" and i > 0:
|
|
648
|
+
renderables.append(Text(""))
|
|
648
649
|
renderables.extend(_render_block(block))
|
|
649
650
|
|
|
650
651
|
if len(renderables) == 1:
|
|
@@ -656,6 +657,96 @@ def render_layout(
|
|
|
656
657
|
# Utilities
|
|
657
658
|
# -----------------------------------------------------------------------------
|
|
658
659
|
|
|
660
|
+
# Pattern for **bold** text
|
|
661
|
+
_BOLD_PATTERN = re.compile(r"\*\*(.+?)\*\*")
|
|
662
|
+
|
|
663
|
+
# Pattern for bullet list items
|
|
664
|
+
_BULLET_PATTERN = re.compile(r"^[-*+]\s+(.*)$")
|
|
665
|
+
|
|
666
|
+
# Pattern for numbered list items
|
|
667
|
+
_NUMBERED_PATTERN = re.compile(r"^(\d+\.)\s+(.*)$")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _parse_inline_formatting(text: str) -> Text:
|
|
671
|
+
"""Parse inline markdown formatting like **bold**.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
text: Text with possible **bold** markers.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Rich Text object with appropriate styling.
|
|
678
|
+
|
|
679
|
+
"""
|
|
680
|
+
result = Text()
|
|
681
|
+
last_end = 0
|
|
682
|
+
|
|
683
|
+
for match in _BOLD_PATTERN.finditer(text):
|
|
684
|
+
# Add text before match
|
|
685
|
+
if match.start() > last_end:
|
|
686
|
+
result.append(text[last_end : match.start()])
|
|
687
|
+
# Add bold text
|
|
688
|
+
result.append(match.group(1), style="bold")
|
|
689
|
+
last_end = match.end()
|
|
690
|
+
|
|
691
|
+
# Add remaining text
|
|
692
|
+
if last_end < len(text):
|
|
693
|
+
result.append(text[last_end:])
|
|
694
|
+
|
|
695
|
+
return result
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _render_box_content(content: str) -> Text:
|
|
699
|
+
"""Render box content with compact spacing.
|
|
700
|
+
|
|
701
|
+
Handles the common pattern of a title line followed by a bullet list,
|
|
702
|
+
without the extra blank line that Rich's Markdown adds.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
content: Markdown content for the box.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Rich Text object with compact formatting.
|
|
709
|
+
|
|
710
|
+
"""
|
|
711
|
+
lines = content.strip().split("\n")
|
|
712
|
+
if not lines:
|
|
713
|
+
return Text()
|
|
714
|
+
|
|
715
|
+
result = Text()
|
|
716
|
+
|
|
717
|
+
for i, line in enumerate(lines):
|
|
718
|
+
stripped = line.strip()
|
|
719
|
+
|
|
720
|
+
if not stripped:
|
|
721
|
+
# Blank line - add single newline
|
|
722
|
+
if i > 0:
|
|
723
|
+
result.append("\n")
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
# Add newline before this line (except first)
|
|
727
|
+
if i > 0 and result.plain and not result.plain.endswith("\n"):
|
|
728
|
+
result.append("\n")
|
|
729
|
+
|
|
730
|
+
# Check for bullet list item
|
|
731
|
+
bullet_match = _BULLET_PATTERN.match(stripped)
|
|
732
|
+
if bullet_match:
|
|
733
|
+
result.append(" • ")
|
|
734
|
+
result.append(_parse_inline_formatting(bullet_match.group(1)))
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
# Check for numbered list item
|
|
738
|
+
numbered_match = _NUMBERED_PATTERN.match(stripped)
|
|
739
|
+
if numbered_match:
|
|
740
|
+
result.append(f" {numbered_match.group(1)} ")
|
|
741
|
+
result.append(_parse_inline_formatting(numbered_match.group(2)))
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# Regular text line
|
|
745
|
+
result.append(_parse_inline_formatting(stripped))
|
|
746
|
+
|
|
747
|
+
return result
|
|
748
|
+
|
|
749
|
+
|
|
659
750
|
# ANSI escape sequence pattern
|
|
660
751
|
_ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
661
752
|
|
prezo/parser.py
CHANGED
|
@@ -40,6 +40,7 @@ class Slide:
|
|
|
40
40
|
raw_content: str = "" # Original content for editing
|
|
41
41
|
notes: str = ""
|
|
42
42
|
images: list[ImageRef] = field(default_factory=list)
|
|
43
|
+
incremental: bool | None = None # Per-slide override for incremental lists
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@dataclass
|
|
@@ -50,7 +51,9 @@ class PresentationConfig:
|
|
|
50
51
|
show_clock: bool | None = None
|
|
51
52
|
show_elapsed: bool | None = None
|
|
52
53
|
countdown_minutes: int | None = None
|
|
54
|
+
time_budget_minutes: int | None = None # Time budget for pacing indicator
|
|
53
55
|
image_mode: str | None = None
|
|
56
|
+
incremental_lists: bool | None = None
|
|
54
57
|
|
|
55
58
|
def merge_to_dict(self) -> dict[str, Any]:
|
|
56
59
|
"""Convert non-None values to a config dict for merging."""
|
|
@@ -63,8 +66,16 @@ class PresentationConfig:
|
|
|
63
66
|
result.setdefault("timer", {})["show_elapsed"] = self.show_elapsed
|
|
64
67
|
if self.countdown_minutes is not None:
|
|
65
68
|
result.setdefault("timer", {})["countdown_minutes"] = self.countdown_minutes
|
|
69
|
+
if self.time_budget_minutes is not None:
|
|
70
|
+
result.setdefault("timer", {})["time_budget_minutes"] = (
|
|
71
|
+
self.time_budget_minutes
|
|
72
|
+
)
|
|
66
73
|
if self.image_mode is not None:
|
|
67
74
|
result.setdefault("images", {})["mode"] = self.image_mode
|
|
75
|
+
if self.incremental_lists is not None:
|
|
76
|
+
result.setdefault("behavior", {})["incremental_lists"] = (
|
|
77
|
+
self.incremental_lists
|
|
78
|
+
)
|
|
68
79
|
return result
|
|
69
80
|
|
|
70
81
|
|
|
@@ -170,6 +181,24 @@ def extract_notes(content: str) -> tuple[str, str]:
|
|
|
170
181
|
return content, ""
|
|
171
182
|
|
|
172
183
|
|
|
184
|
+
def extract_slide_incremental(content: str) -> bool | None:
|
|
185
|
+
"""Extract per-slide incremental directive from content.
|
|
186
|
+
|
|
187
|
+
Looks for:
|
|
188
|
+
- <!-- incremental --> to enable incremental lists for this slide
|
|
189
|
+
- <!-- no-incremental --> to disable incremental lists for this slide
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if incremental enabled, False if disabled, None if not specified.
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
if re.search(r"<!--\s*incremental\s*-->", content, re.IGNORECASE):
|
|
196
|
+
return True
|
|
197
|
+
if re.search(r"<!--\s*no-incremental\s*-->", content, re.IGNORECASE):
|
|
198
|
+
return False
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
173
202
|
def extract_prezo_directives(content: str) -> PresentationConfig:
|
|
174
203
|
"""Extract Prezo-specific directives from presentation content.
|
|
175
204
|
|
|
@@ -215,8 +244,13 @@ def extract_prezo_directives(content: str) -> PresentationConfig:
|
|
|
215
244
|
elif key in ("countdown_minutes", "countdown", "countdownminutes"):
|
|
216
245
|
with contextlib.suppress(ValueError):
|
|
217
246
|
config.countdown_minutes = int(value)
|
|
247
|
+
elif key in ("time_budget", "time_budget_minutes", "timebudget"):
|
|
248
|
+
with contextlib.suppress(ValueError):
|
|
249
|
+
config.time_budget_minutes = int(value)
|
|
218
250
|
elif key in ("image_mode", "imagemode", "images"):
|
|
219
251
|
config.image_mode = value
|
|
252
|
+
elif key in ("incremental_lists", "incremental", "incrementallists"):
|
|
253
|
+
config.incremental_lists = value.lower() in ("true", "1", "yes", "on")
|
|
220
254
|
|
|
221
255
|
return config
|
|
222
256
|
|
|
@@ -420,6 +454,8 @@ def _parse_content(text: str, source_path: Path | None) -> Presentation:
|
|
|
420
454
|
slide_content, notes = extract_notes(raw_slide)
|
|
421
455
|
# Extract images BEFORE cleaning (clean_marp_directives removes bg images)
|
|
422
456
|
images = extract_images(slide_content)
|
|
457
|
+
# Extract per-slide incremental setting
|
|
458
|
+
incremental = extract_slide_incremental(slide_content)
|
|
423
459
|
cleaned_content = clean_marp_directives(slide_content).strip()
|
|
424
460
|
slide = Slide(
|
|
425
461
|
content=cleaned_content,
|
|
@@ -427,6 +463,7 @@ def _parse_content(text: str, source_path: Path | None) -> Presentation:
|
|
|
427
463
|
raw_content=raw_slide,
|
|
428
464
|
notes=notes.strip(),
|
|
429
465
|
images=images,
|
|
466
|
+
incremental=incremental,
|
|
430
467
|
)
|
|
431
468
|
presentation.slides.append(slide)
|
|
432
469
|
|
prezo/screens/help.py
CHANGED
|
@@ -35,9 +35,11 @@ HELP_CONTENT = """\
|
|
|
35
35
|
|-----|--------|
|
|
36
36
|
| **p** | Toggle presenter notes |
|
|
37
37
|
| **c** | Cycle clock/timer modes |
|
|
38
|
+
| **s** | Start/stop timer |
|
|
38
39
|
| **T** | Cycle through themes |
|
|
39
40
|
| **b** | Blackout screen |
|
|
40
41
|
| **w** | Whiteout screen |
|
|
42
|
+
| **i** | View image (native quality) |
|
|
41
43
|
|
|
42
44
|
## Editing & Files
|
|
43
45
|
|
|
@@ -81,6 +83,8 @@ countdown_minutes: 45
|
|
|
81
83
|
- `show_clock`: true/false
|
|
82
84
|
- `show_elapsed`: true/false
|
|
83
85
|
- `countdown_minutes`: number
|
|
86
|
+
- `time_budget`: minutes (shows pacing indicator)
|
|
87
|
+
- `incremental`: true/false (reveal list items one at a time)
|
|
84
88
|
|
|
85
89
|
## Documentation
|
|
86
90
|
|
prezo/widgets/status_bar.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
+
from rich.text import Text
|
|
8
9
|
from textual.reactive import reactive
|
|
9
10
|
from textual.widgets import Static
|
|
10
11
|
|
|
@@ -56,11 +57,19 @@ class StatusBar(Static):
|
|
|
56
57
|
show_elapsed: reactive[bool] = reactive(True)
|
|
57
58
|
show_countdown: reactive[bool] = reactive(False)
|
|
58
59
|
countdown_minutes: reactive[int] = reactive(0)
|
|
60
|
+
# Incremental reveal indicator
|
|
61
|
+
reveal_current: reactive[int] = reactive(-1) # -1 = not in reveal mode
|
|
62
|
+
reveal_total: reactive[int] = reactive(0)
|
|
63
|
+
# Timer running state
|
|
64
|
+
timer_running: reactive[bool] = reactive(True)
|
|
65
|
+
# Time budget for pacing indicator (0 = disabled)
|
|
66
|
+
time_budget_minutes: reactive[int] = reactive(0)
|
|
59
67
|
|
|
60
68
|
def __init__(self, **kwargs) -> None:
|
|
61
69
|
"""Initialize the status bar."""
|
|
62
70
|
super().__init__(**kwargs)
|
|
63
71
|
self._start_time: datetime | None = None
|
|
72
|
+
self._elapsed_when_paused: float = 0.0 # Accumulated time when paused
|
|
64
73
|
self._timer: Timer | None = None
|
|
65
74
|
|
|
66
75
|
def on_mount(self) -> None:
|
|
@@ -72,11 +81,26 @@ class StatusBar(Static):
|
|
|
72
81
|
"""Timer callback to refresh the display."""
|
|
73
82
|
self.refresh()
|
|
74
83
|
|
|
75
|
-
def render(self) ->
|
|
84
|
+
def render(self) -> Text:
|
|
76
85
|
"""Render the status bar content."""
|
|
86
|
+
result = Text()
|
|
87
|
+
|
|
77
88
|
# Progress part
|
|
78
89
|
bar = format_progress_bar(self.current, self.total, width=20)
|
|
79
|
-
progress = f"{bar} {self.current + 1}/{self.total}"
|
|
90
|
+
progress = f" {bar} {self.current + 1}/{self.total}"
|
|
91
|
+
result.append(progress)
|
|
92
|
+
|
|
93
|
+
# Reveal indicator (shows remaining list items)
|
|
94
|
+
if self.reveal_current >= 0 and self.reveal_total > 0:
|
|
95
|
+
remaining = self.reveal_total - self.reveal_current - 1
|
|
96
|
+
if remaining > 0:
|
|
97
|
+
result.append(f" [+{remaining}]")
|
|
98
|
+
|
|
99
|
+
# Pacing indicator (if time budget is set)
|
|
100
|
+
pacing = self._get_pacing_indicator()
|
|
101
|
+
if pacing:
|
|
102
|
+
result.append(" ")
|
|
103
|
+
result.append_text(pacing)
|
|
80
104
|
|
|
81
105
|
# Clock part
|
|
82
106
|
clock_parts = []
|
|
@@ -86,26 +110,100 @@ class StatusBar(Static):
|
|
|
86
110
|
)
|
|
87
111
|
|
|
88
112
|
if self.show_elapsed and self._start_time:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
elapsed_secs = self._get_elapsed_seconds()
|
|
114
|
+
# Show pause indicator when timer is stopped
|
|
115
|
+
pause_indicator = " ⏸" if not self.timer_running else ""
|
|
116
|
+
clock_parts.append(f"+{format_time(elapsed_secs)}{pause_indicator}")
|
|
92
117
|
|
|
93
118
|
if self.show_countdown and self.countdown_minutes > 0 and self._start_time:
|
|
94
119
|
total_secs = self.countdown_minutes * 60
|
|
95
|
-
|
|
96
|
-
remaining = total_secs -
|
|
120
|
+
elapsed_secs = self._get_elapsed_seconds()
|
|
121
|
+
remaining = total_secs - elapsed_secs
|
|
97
122
|
clock_parts.append(f"-{format_time(remaining)}")
|
|
98
123
|
|
|
99
|
-
|
|
124
|
+
if clock_parts:
|
|
125
|
+
# Add separator if we have pacing or progress
|
|
126
|
+
result.append(" ")
|
|
127
|
+
result.append(" │ ".join(clock_parts))
|
|
128
|
+
|
|
129
|
+
result.append(" ")
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def _get_pacing_indicator(self) -> Text | None:
|
|
133
|
+
"""Get the pacing indicator with color based on time budget.
|
|
100
134
|
|
|
101
|
-
|
|
102
|
-
if
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
Returns colored indicator:
|
|
136
|
+
- Green (▲ -Xm) if more than 10% ahead
|
|
137
|
+
- Red (▼ +Xm) if more than 10% behind
|
|
138
|
+
- None if no time budget or on track
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
if self.time_budget_minutes <= 0 or self.total <= 0:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
elapsed_secs = self._get_elapsed_seconds()
|
|
145
|
+
total_budget_secs = self.time_budget_minutes * 60
|
|
146
|
+
|
|
147
|
+
# Expected time for current slide position
|
|
148
|
+
# (current is 0-indexed, so current+1 gives slides completed)
|
|
149
|
+
expected_secs = (total_budget_secs / self.total) * (self.current + 1)
|
|
150
|
+
|
|
151
|
+
# Calculate difference (positive = behind, negative = ahead)
|
|
152
|
+
diff_secs = elapsed_secs - expected_secs
|
|
153
|
+
diff_pct = diff_secs / expected_secs if expected_secs > 0 else 0
|
|
154
|
+
|
|
155
|
+
# More than 10% threshold
|
|
156
|
+
if diff_pct > 0.1:
|
|
157
|
+
# Behind schedule - need to speed up (red background, white text)
|
|
158
|
+
diff_mins = int(abs(diff_secs) / 60)
|
|
159
|
+
diff_remaining = int(abs(diff_secs) % 60)
|
|
160
|
+
if diff_mins > 0:
|
|
161
|
+
indicator = f" ▼ +{diff_mins}m{diff_remaining:02d}s "
|
|
162
|
+
else:
|
|
163
|
+
indicator = f" ▼ +{diff_remaining}s "
|
|
164
|
+
return Text(indicator, style="bold white on red")
|
|
165
|
+
if diff_pct < -0.1:
|
|
166
|
+
# Ahead of schedule (green background, white text)
|
|
167
|
+
diff_mins = int(abs(diff_secs) / 60)
|
|
168
|
+
diff_remaining = int(abs(diff_secs) % 60)
|
|
169
|
+
if diff_mins > 0:
|
|
170
|
+
indicator = f" ▲ -{diff_mins}m{diff_remaining:02d}s "
|
|
171
|
+
else:
|
|
172
|
+
indicator = f" ▲ -{diff_remaining}s "
|
|
173
|
+
return Text(indicator, style="bold white on green")
|
|
174
|
+
|
|
175
|
+
# On track - no indicator needed
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _get_elapsed_seconds(self) -> int:
|
|
179
|
+
"""Get total elapsed seconds, accounting for pauses."""
|
|
180
|
+
if not self._start_time:
|
|
181
|
+
return 0
|
|
182
|
+
if self.timer_running:
|
|
183
|
+
current_elapsed = datetime.now(tz=timezone.utc) - self._start_time
|
|
184
|
+
return int(current_elapsed.total_seconds() + self._elapsed_when_paused)
|
|
185
|
+
# When paused, just return the accumulated time
|
|
186
|
+
return int(self._elapsed_when_paused)
|
|
105
187
|
|
|
106
188
|
def reset_timer(self) -> None:
|
|
107
189
|
"""Reset the elapsed timer."""
|
|
108
190
|
self._start_time = datetime.now(tz=timezone.utc)
|
|
191
|
+
self._elapsed_when_paused = 0.0
|
|
192
|
+
self.timer_running = True
|
|
193
|
+
self.refresh()
|
|
194
|
+
|
|
195
|
+
def toggle_timer(self) -> None:
|
|
196
|
+
"""Start or stop the elapsed timer."""
|
|
197
|
+
if self.timer_running:
|
|
198
|
+
# Pausing: save current elapsed time
|
|
199
|
+
if self._start_time:
|
|
200
|
+
current_elapsed = datetime.now(tz=timezone.utc) - self._start_time
|
|
201
|
+
self._elapsed_when_paused += current_elapsed.total_seconds()
|
|
202
|
+
self.timer_running = False
|
|
203
|
+
else:
|
|
204
|
+
# Resuming: reset start time (accumulated time is preserved)
|
|
205
|
+
self._start_time = datetime.now(tz=timezone.utc)
|
|
206
|
+
self.timer_running = True
|
|
109
207
|
self.refresh()
|
|
110
208
|
|
|
111
209
|
def toggle_clock(self) -> None:
|
|
@@ -142,6 +240,22 @@ class StatusBar(Static):
|
|
|
142
240
|
"""React to elapsed time visibility changes."""
|
|
143
241
|
self.refresh()
|
|
144
242
|
|
|
243
|
+
def watch_reveal_current(self, value: int) -> None:
|
|
244
|
+
"""React to reveal indicator changes."""
|
|
245
|
+
self.refresh()
|
|
246
|
+
|
|
247
|
+
def watch_reveal_total(self, value: int) -> None:
|
|
248
|
+
"""React to reveal total changes."""
|
|
249
|
+
self.refresh()
|
|
250
|
+
|
|
251
|
+
def watch_timer_running(self, value: bool) -> None:
|
|
252
|
+
"""React to timer running state changes."""
|
|
253
|
+
self.refresh()
|
|
254
|
+
|
|
255
|
+
def watch_time_budget_minutes(self, value: int) -> None:
|
|
256
|
+
"""React to time budget changes."""
|
|
257
|
+
self.refresh()
|
|
258
|
+
|
|
145
259
|
|
|
146
260
|
# Keep these for backwards compatibility and separate use
|
|
147
261
|
class ProgressBar(Static):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: prezo
|
|
3
|
-
Version: 2026.1
|
|
3
|
+
Version: 2026.2.1
|
|
4
4
|
Summary: A TUI-based presentation tool for the terminal, built with Textual.
|
|
5
5
|
Author: Stefane Fermigier
|
|
6
6
|
Author-email: Stefane Fermigier <sf@fermigier.com>
|
|
@@ -20,6 +20,7 @@ Display presentations written in Markdown using conventions similar to those of
|
|
|
20
20
|
|
|
21
21
|
- **Markdown presentations** - MARP/Deckset format with `---` slide separators
|
|
22
22
|
- **Column layouts** - Pandoc-style fenced divs for multi-column slides (`::: columns`)
|
|
23
|
+
- **Incremental lists** - Reveal list items one at a time (`-I` flag, like Pandoc)
|
|
23
24
|
- **Live reload** - Auto-refresh when file changes (1s polling)
|
|
24
25
|
- **Keyboard navigation** - Vim-style keys, arrow keys, and more
|
|
25
26
|
- **Slide overview** - Grid view for quick navigation (`o`)
|
|
@@ -71,6 +72,9 @@ prezo -c myconfig.toml presentation.md
|
|
|
71
72
|
# Set image rendering mode
|
|
72
73
|
prezo --image-mode ascii presentation.md # Options: auto, kitty, sixel, iterm, ascii, none
|
|
73
74
|
|
|
75
|
+
# Incremental list reveal (reveal items one at a time)
|
|
76
|
+
prezo -I presentation.md
|
|
77
|
+
|
|
74
78
|
# Export to PDF
|
|
75
79
|
prezo -e pdf presentation.md
|
|
76
80
|
|
|
@@ -92,6 +96,7 @@ prezo -e pdf presentation.md --theme light --size 100x30 --no-chrome
|
|
|
92
96
|
| `t` | Table of contents |
|
|
93
97
|
| `p` | Toggle notes panel |
|
|
94
98
|
| `c` | Cycle clock display |
|
|
99
|
+
| `s` | Start/stop timer |
|
|
95
100
|
| `T` | Cycle theme |
|
|
96
101
|
| `b` | Blackout screen |
|
|
97
102
|
| `w` | Whiteout screen |
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
prezo/__init__.py,sha256
|
|
2
|
-
prezo/app.py,sha256=
|
|
3
|
-
prezo/config.py,sha256=
|
|
1
|
+
prezo/__init__.py,sha256=-w5od4EwSOtxdqJGhmeGm_lncSQUdY_OCsH06Ha8hAQ,7598
|
|
2
|
+
prezo/app.py,sha256=MwNfymSXEIQm42wXCgLw7VV_98EM1ngSdjGrrNrK9Ds,42440
|
|
3
|
+
prezo/config.py,sha256=iViAqEqv48uK4OpqfoD8uiVWFpmPUV18Dhz58tK-b3U,6793
|
|
4
4
|
prezo/export/__init__.py,sha256=jdf4Xu71aUKPBXUt8k8TEUzgMee4bfEh71iJGZXtVKE,1010
|
|
5
5
|
prezo/export/common.py,sha256=W9Gn2_wjd3EPuenAECrs6pqsJEWFBJHtGs8y6-4VEKQ,2451
|
|
6
6
|
prezo/export/html.py,sha256=GLRjTvZUEmvee6F2NlU3sn94H47PqLTLpaC3Ab_kxo8,8787
|
|
7
7
|
prezo/export/images.py,sha256=TNTGIYNRTxwVjbHqZXYjIj0gouV_Dy1_DtenMlxsquE,7410
|
|
8
8
|
prezo/export/pdf.py,sha256=9rzybk8o1vEu_JwvKB2abzrYoKfpEA8IP5pEFJHF5WM,13942
|
|
9
|
-
prezo/export/svg.py,sha256=
|
|
9
|
+
prezo/export/svg.py,sha256=xRr4wMRkPGJrS0Ue69ldjA6VneZ3D7oYRvDbZocwrHs,5539
|
|
10
10
|
prezo/images/__init__.py,sha256=xrWSR3z0SXYpLtjIvR2VOMxiJGkxEsls5-EOs9GecFA,324
|
|
11
11
|
prezo/images/ascii.py,sha256=aNz02jN4rkDw0WzmkGDrAGw1R1dY5QGREvIIPI6jwow,7613
|
|
12
12
|
prezo/images/base.py,sha256=STuS57AVSJ2lzwyn0QrIceGaSd2IWEiLGN-elT3u3AM,2749
|
|
@@ -16,13 +16,13 @@ prezo/images/kitty.py,sha256=mWR-tIE_WDP5BjOkQydPpxWBBGNaZL8PkMICesWQid8,10883
|
|
|
16
16
|
prezo/images/overlay.py,sha256=lWIAvINxZrKembtB0gzWWaUoedNt7beFU4OhErfwWaw,9600
|
|
17
17
|
prezo/images/processor.py,sha256=zMcfwltecup_AX2FhUIlPdO7c87a9jw7P9tLTIkr54U,4069
|
|
18
18
|
prezo/images/sixel.py,sha256=2IeKDiMsWU1Tn3HYI3PC972ygxKGqpfz6tnhQcM_sVM,5604
|
|
19
|
-
prezo/layout.py,sha256=
|
|
20
|
-
prezo/parser.py,sha256=
|
|
19
|
+
prezo/layout.py,sha256=FN-nMX902jx9MA7TnySbPskmnyWAiziv_xBvTTYrFd0,22463
|
|
20
|
+
prezo/parser.py,sha256=bD3CFoFZ_vCa5x8US1vsKD1u6kJDC7d1sRqnLh8ZVUU,16680
|
|
21
21
|
prezo/screens/__init__.py,sha256=xHG9jNJz4vi1tpneSEVlD0D9I0M2U4gAGk6-R9xbUf4,508
|
|
22
22
|
prezo/screens/base.py,sha256=2n6Uj8evfIbcpn4AVYNG5iM_k7rIJ3Vwmor_xrQPU9E,2057
|
|
23
23
|
prezo/screens/blackout.py,sha256=wPSdD9lgu8ykAIQjU1OesnmjQoQEn9BdC4TEpABYxW4,1640
|
|
24
24
|
prezo/screens/goto.py,sha256=l9q6RAU8GX8WIALvbaPE3rcszrYWsJob8lGIDvUaWFM,2687
|
|
25
|
-
prezo/screens/help.py,sha256=
|
|
25
|
+
prezo/screens/help.py,sha256=Sulw8nljnLbNZRwF7w3zFbaIe56Vx1mU7uuCWCJG0H4,3168
|
|
26
26
|
prezo/screens/overview.py,sha256=s9-ifbcnXYhbxb_Kl2UhpB3IE7msInX6LWB-J1dazLo,5382
|
|
27
27
|
prezo/screens/search.py,sha256=3YG9WLGEIKW3YHpM0K1lgwhuqBveXd8ZoQZ178_zGd4,7809
|
|
28
28
|
prezo/screens/toc.py,sha256=8WYb5nbgP9agY-hUTATxLU4X1uka_bc2MN86hFW4aRg,8241
|
|
@@ -32,8 +32,8 @@ prezo/widgets/__init__.py,sha256=6qAbVVWG2fb4DLv0EzMQ-Qbi74SviXIo-7D7DyDwnrI,378
|
|
|
32
32
|
prezo/widgets/image_display.py,sha256=8IKncaoC2iWebmJQp_QomF7UVgRxD4WThOshN1Nht2M,3361
|
|
33
33
|
prezo/widgets/slide_button.py,sha256=g5mvtCZSorTIZp_PXgHYeYeeCSNFy0pW3K7iDlZu7yA,2012
|
|
34
34
|
prezo/widgets/slide_content.py,sha256=AO3doIuPBSo5vec_d1xE5F8YMJWxzOq5IFO7gSYSWNw,2300
|
|
35
|
-
prezo/widgets/status_bar.py,sha256=
|
|
36
|
-
prezo-2026.1.
|
|
37
|
-
prezo-2026.1.
|
|
38
|
-
prezo-2026.1.
|
|
39
|
-
prezo-2026.1.
|
|
35
|
+
prezo/widgets/status_bar.py,sha256=wt7jrijSsnYDBnpVZDqgztBcbA30HdOXnUiEIhw93yQ,12695
|
|
36
|
+
prezo-2026.2.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
37
|
+
prezo-2026.2.1.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
|
|
38
|
+
prezo-2026.2.1.dist-info/METADATA,sha256=lQHxjNBic-uFI2SRk_B7fC1vF5FFlcLipMa_28nCfaE,5511
|
|
39
|
+
prezo-2026.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|