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 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(file_path, watch=not args.no_watch, config=config)
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
- self.current_slide = max(0, self.presentation.total_slides - 1)
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
- self.current_slide = last_pos
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 = 0
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
- self.query_one("#slide-content", SlideContent).set_content(
587
- slide.content.strip()
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
- self.presentation
642
- and self.current_slide < self.presentation.total_slides - 1
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(presentation_path, watch=watch, config=config)
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
- # SVG template without window chrome (for printing)
17
- # Uses Rich's template format: {var} for substitution, {{ }} for literal braces
18
- SVG_FORMAT_NO_CHROME = """\
19
- <svg class="rich-terminal" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
20
- <!-- Generated with Rich https://www.textualize.io -->
21
- <style>
22
-
23
- @font-face {{
24
- font-family: "Fira Code";
25
- src: local("FiraCode-Regular"),
26
- url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
27
- url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
28
- font-style: normal;
29
- font-weight: 400;
30
- }}
31
- @font-face {{
32
- font-family: "Fira Code";
33
- src: local("FiraCode-Bold"),
34
- url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
35
- url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
36
- font-style: bold;
37
- font-weight: 700;
38
- }}
39
-
40
- .{{unique_id}}-matrix {{
41
- font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace;
42
- font-size: {{char_height}}px;
43
- line-height: {{line_height}}px;
44
- font-variant-east-asian: full-width;
45
- /* Disable ligatures and ensure consistent character widths */
46
- font-feature-settings: "liga" 0, "calt" 0, "dlig" 0;
47
- font-variant-ligatures: none;
48
- letter-spacing: 0;
49
- word-spacing: 0;
50
- white-space: pre;
51
- }}
52
-
53
- .{{unique_id}}-matrix text {{
54
- /* Force uniform character spacing for box-drawing chars */
55
- text-rendering: geometricPrecision;
56
- }}
57
-
58
- {{styles}}
59
- </style>
60
-
61
- <defs>
62
- <clipPath id="{{unique_id}}-clip-terminal">
63
- <rect x="0" y="0" width="{{width}}" height="{{height}}" />
64
- </clipPath>
65
- {{lines}}
66
- </defs>
67
-
68
- <g transform="translate(0, 0)" clip-path="url(#{{unique_id}}-clip-terminal)">
69
- {{backgrounds}}
70
- <g class="{{unique_id}}-matrix">
71
- {{matrix}}
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 (height - 2 for status bar and padding)
125
- panel_height = height - 2
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
- if chrome:
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
- return svg.replace(
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
- yield Text("")
542
- md = Markdown(self.content)
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
 
@@ -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) -> str:
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
- elapsed = datetime.now(tz=timezone.utc) - self._start_time
90
- elapsed_secs = int(elapsed.total_seconds())
91
- clock_parts.append(f"+{format_time(elapsed_secs)}")
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
- elapsed = datetime.now(tz=timezone.utc) - self._start_time
96
- remaining = total_secs - int(elapsed.total_seconds())
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
- clock = " │ ".join(clock_parts) if clock_parts else ""
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
- # Combine with spacing
102
- if clock:
103
- return f" {progress} {clock} "
104
- return f" {progress} "
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
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=RDpFh0F3DGdMB08n7G3HM-c14JAoqvElq4DyXLSPDQg,6740
2
- prezo/app.py,sha256=3RPSx56hjyyG55ueNWHvuUNe-KkQ3ZzidNgBRc3I2WQ,31713
3
- prezo/config.py,sha256=DLHUQkThxhmYXQE1AgpWkPvtNlDwOxQRSNjRrfJJEew,6646
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=gxhRXpLiKcVr4NSF8nwaid74p8f9dvj4RwXwvMP0EbU,5698
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=xy-UaZvU2ZDe0H7XjMCDb30tog4LRdZKB5OUh_r0aqo,19929
20
- prezo/parser.py,sha256=f1eJtr9xVnxa35jgQ3GvKwNa6TvaXrIKQWgBB_geVAI,15058
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=fjwHp9qPMmyRIaME-Bcz-g6bn8UrtbL_Dk269QSU-zs,2987
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=Wcun71kg2Q4s5aduPwTvS4kDHZj5p-zDmD7Cx3_ZFP4,8136
36
- prezo-2026.1.3.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
37
- prezo-2026.1.3.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
38
- prezo-2026.1.3.dist-info/METADATA,sha256=mv3i3_l0Rq03cmGDQzwBeZ9FiUIJ6enFmJNSvm83v2I,5320
39
- prezo-2026.1.3.dist-info/RECORD,,
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,,