prezo 2026.1.4__tar.gz → 2026.2.1__tar.gz

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.
Files changed (38) hide show
  1. {prezo-2026.1.4 → prezo-2026.2.1}/PKG-INFO +6 -1
  2. {prezo-2026.1.4 → prezo-2026.2.1}/README.md +5 -0
  3. {prezo-2026.1.4 → prezo-2026.2.1}/pyproject.toml +1 -1
  4. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/__init__.py +23 -0
  5. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/app.py +28 -1
  6. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/config.py +1 -0
  7. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/svg.py +74 -71
  8. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/parser.py +8 -0
  9. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/help.py +4 -0
  10. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/widgets/status_bar.py +110 -14
  11. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/__init__.py +0 -0
  12. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/common.py +0 -0
  13. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/html.py +0 -0
  14. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/images.py +0 -0
  15. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/export/pdf.py +0 -0
  16. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/__init__.py +0 -0
  17. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/ascii.py +0 -0
  18. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/base.py +0 -0
  19. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/chafa.py +0 -0
  20. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/iterm.py +0 -0
  21. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/kitty.py +0 -0
  22. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/overlay.py +0 -0
  23. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/processor.py +0 -0
  24. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/images/sixel.py +0 -0
  25. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/layout.py +0 -0
  26. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/__init__.py +0 -0
  27. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/base.py +0 -0
  28. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/blackout.py +0 -0
  29. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/goto.py +0 -0
  30. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/overview.py +0 -0
  31. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/search.py +0 -0
  32. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/screens/toc.py +0 -0
  33. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/terminal.py +0 -0
  34. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/themes.py +0 -0
  35. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/widgets/__init__.py +0 -0
  36. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/widgets/image_display.py +0 -0
  37. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/widgets/slide_button.py +0 -0
  38. {prezo-2026.1.4 → prezo-2026.2.1}/src/prezo/widgets/slide_content.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: prezo
3
- Version: 2026.1.4
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 |
@@ -8,6 +8,7 @@ Display presentations written in Markdown using conventions similar to those of
8
8
 
9
9
  - **Markdown presentations** - MARP/Deckset format with `---` slide separators
10
10
  - **Column layouts** - Pandoc-style fenced divs for multi-column slides (`::: columns`)
11
+ - **Incremental lists** - Reveal list items one at a time (`-I` flag, like Pandoc)
11
12
  - **Live reload** - Auto-refresh when file changes (1s polling)
12
13
  - **Keyboard navigation** - Vim-style keys, arrow keys, and more
13
14
  - **Slide overview** - Grid view for quick navigation (`o`)
@@ -59,6 +60,9 @@ prezo -c myconfig.toml presentation.md
59
60
  # Set image rendering mode
60
61
  prezo --image-mode ascii presentation.md # Options: auto, kitty, sixel, iterm, ascii, none
61
62
 
63
+ # Incremental list reveal (reveal items one at a time)
64
+ prezo -I presentation.md
65
+
62
66
  # Export to PDF
63
67
  prezo -e pdf presentation.md
64
68
 
@@ -80,6 +84,7 @@ prezo -e pdf presentation.md --theme light --size 100x30 --no-chrome
80
84
  | `t` | Table of contents |
81
85
  | `p` | Toggle notes panel |
82
86
  | `c` | Cycle clock display |
87
+ | `s` | Start/stop timer |
83
88
  | `T` | Cycle theme |
84
89
  | `b` | Blackout screen |
85
90
  | `w` | Whiteout screen |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prezo"
3
- version = "2026.1.4"
3
+ version = "2026.2.1"
4
4
  description = "A TUI-based presentation tool for the terminal, built with Textual."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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="?",
@@ -165,6 +181,12 @@ def main() -> None:
165
181
  action="store_true",
166
182
  help="Display lists incrementally, one item at a time (like Pandoc)",
167
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
+ )
168
190
 
169
191
  args = parser.parse_args()
170
192
 
@@ -247,4 +269,5 @@ def main() -> None:
247
269
  watch=not args.no_watch,
248
270
  config=config,
249
271
  incremental=args.incremental,
272
+ time_budget=args.time_budget,
250
273
  )
@@ -67,6 +67,7 @@ prezo <presentation.md>
67
67
  | **t** | Table of contents |
68
68
  | **p** | Toggle notes |
69
69
  | **c** | Toggle clock |
70
+ | **s** | Start/stop timer |
70
71
  | **b** | Blackout screen |
71
72
  | **e** | Edit current slide |
72
73
  | **r** | Reload file |
@@ -258,6 +259,7 @@ class PrezoCommands(Provider):
258
259
  ("Search Slides", "search", "Search slides by content (/)"),
259
260
  ("Toggle Notes", "toggle_notes", "Show/hide presenter notes (p)"),
260
261
  ("Toggle Clock", "toggle_clock", "Cycle clock display mode (c)"),
262
+ ("Start/Stop Timer", "toggle_timer", "Start or stop elapsed timer (S)"),
261
263
  ("Help", "show_help", "Show keyboard shortcuts (?)"),
262
264
  ]
263
265
  )
@@ -446,6 +448,8 @@ class PrezoApp(App):
446
448
  Binding("t", "show_toc", "TOC", show=True),
447
449
  Binding("p", "toggle_notes", "Notes", show=True),
448
450
  Binding("c", "toggle_clock", "Clock", show=False),
451
+ Binding("s", "toggle_timer", "Timer", show=False),
452
+ Binding("S", "toggle_timer", "Timer", show=False),
449
453
  Binding("T", "cycle_theme", "Theme", show=False),
450
454
  Binding("b", "blackout", "Blackout", show=False),
451
455
  Binding("w", "whiteout", "Whiteout", show=False),
@@ -469,6 +473,7 @@ class PrezoApp(App):
469
473
  watch: bool | None = None,
470
474
  config: Config | None = None,
471
475
  incremental: bool = False,
476
+ time_budget: int | None = None,
472
477
  ) -> None:
473
478
  """Initialize the Prezo application.
474
479
 
@@ -477,6 +482,7 @@ class PrezoApp(App):
477
482
  watch: Whether to enable file watching for live reload.
478
483
  config: Optional config override. Uses global config if None.
479
484
  incremental: Whether to display lists incrementally (-I flag).
485
+ time_budget: Time budget in minutes for pacing indicator.
480
486
 
481
487
  """
482
488
  super().__init__()
@@ -489,6 +495,9 @@ class PrezoApp(App):
489
495
  # Incremental lists: CLI flag overrides config
490
496
  self.incremental_cli = incremental
491
497
 
498
+ # Time budget: CLI flag overrides config/presentation directives
499
+ self.time_budget_cli = time_budget
500
+
492
501
  # Use config for watch if not explicitly set
493
502
  if watch is None:
494
503
  self.watch_enabled = self.config.behavior.auto_reload
@@ -706,6 +715,7 @@ class PrezoApp(App):
706
715
  show_clock = self.config.timer.show_clock
707
716
  show_elapsed = self.config.timer.show_elapsed
708
717
  countdown = self.config.timer.countdown_minutes
718
+ time_budget = self.config.timer.time_budget_minutes
709
719
 
710
720
  # Override with presentation directives if specified
711
721
  if self.presentation:
@@ -716,12 +726,19 @@ class PrezoApp(App):
716
726
  show_elapsed = directives.show_elapsed
717
727
  if directives.countdown_minutes is not None:
718
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
719
735
 
720
736
  # Apply to status bar
721
737
  status_bar.show_clock = show_clock
722
738
  status_bar.show_elapsed = show_elapsed
723
739
  status_bar.countdown_minutes = countdown
724
740
  status_bar.show_countdown = countdown > 0
741
+ status_bar.time_budget_minutes = time_budget
725
742
 
726
743
  def _show_welcome(self) -> None:
727
744
  """Show welcome message when no presentation is loaded."""
@@ -977,6 +994,10 @@ class PrezoApp(App):
977
994
  """Cycle through clock display modes."""
978
995
  self.query_one("#status-bar", StatusBar).toggle_clock()
979
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
+
980
1001
  def action_cycle_theme(self) -> None:
981
1002
  """Cycle through available themes."""
982
1003
  self.app_theme = get_next_theme(self.app_theme)
@@ -1188,6 +1209,7 @@ def run_app(
1188
1209
  watch: bool | None = None,
1189
1210
  config: Config | None = None,
1190
1211
  incremental: bool = False,
1212
+ time_budget: int | None = None,
1191
1213
  ) -> None:
1192
1214
  """Run the Prezo application.
1193
1215
 
@@ -1196,9 +1218,14 @@ def run_app(
1196
1218
  watch: Whether to watch for file changes. Uses config default if None.
1197
1219
  config: Optional config override. Uses global config if None.
1198
1220
  incremental: Whether to display lists incrementally (-I flag).
1221
+ time_budget: Time budget in minutes for pacing indicator.
1199
1222
 
1200
1223
  """
1201
1224
  app = PrezoApp(
1202
- presentation_path, watch=watch, config=config, incremental=incremental
1225
+ presentation_path,
1226
+ watch=watch,
1227
+ config=config,
1228
+ incremental=incremental,
1229
+ time_budget=time_budget,
1203
1230
  )
1204
1231
  app.run()
@@ -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
@@ -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
@@ -51,6 +51,7 @@ class PresentationConfig:
51
51
  show_clock: bool | None = None
52
52
  show_elapsed: bool | None = None
53
53
  countdown_minutes: int | None = None
54
+ time_budget_minutes: int | None = None # Time budget for pacing indicator
54
55
  image_mode: str | None = None
55
56
  incremental_lists: bool | None = None
56
57
 
@@ -65,6 +66,10 @@ class PresentationConfig:
65
66
  result.setdefault("timer", {})["show_elapsed"] = self.show_elapsed
66
67
  if self.countdown_minutes is not None:
67
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
+ )
68
73
  if self.image_mode is not None:
69
74
  result.setdefault("images", {})["mode"] = self.image_mode
70
75
  if self.incremental_lists is not None:
@@ -239,6 +244,9 @@ def extract_prezo_directives(content: str) -> PresentationConfig:
239
244
  elif key in ("countdown_minutes", "countdown", "countdownminutes"):
240
245
  with contextlib.suppress(ValueError):
241
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)
242
250
  elif key in ("image_mode", "imagemode", "images"):
243
251
  config.image_mode = value
244
252
  elif key in ("incremental_lists", "incremental", "incrementallists"):
@@ -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
 
@@ -59,11 +60,16 @@ class StatusBar(Static):
59
60
  # Incremental reveal indicator
60
61
  reveal_current: reactive[int] = reactive(-1) # -1 = not in reveal mode
61
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)
62
67
 
63
68
  def __init__(self, **kwargs) -> None:
64
69
  """Initialize the status bar."""
65
70
  super().__init__(**kwargs)
66
71
  self._start_time: datetime | None = None
72
+ self._elapsed_when_paused: float = 0.0 # Accumulated time when paused
67
73
  self._timer: Timer | None = None
68
74
 
69
75
  def on_mount(self) -> None:
@@ -75,18 +81,26 @@ class StatusBar(Static):
75
81
  """Timer callback to refresh the display."""
76
82
  self.refresh()
77
83
 
78
- def render(self) -> str:
84
+ def render(self) -> Text:
79
85
  """Render the status bar content."""
86
+ result = Text()
87
+
80
88
  # Progress part
81
89
  bar = format_progress_bar(self.current, self.total, width=20)
82
- progress = f"{bar} {self.current + 1}/{self.total}"
90
+ progress = f" {bar} {self.current + 1}/{self.total}"
91
+ result.append(progress)
83
92
 
84
93
  # Reveal indicator (shows remaining list items)
85
- reveal = ""
86
94
  if self.reveal_current >= 0 and self.reveal_total > 0:
87
95
  remaining = self.reveal_total - self.reveal_current - 1
88
96
  if remaining > 0:
89
- reveal = f" [+{remaining}]"
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)
90
104
 
91
105
  # Clock part
92
106
  clock_parts = []
@@ -96,26 +110,100 @@ class StatusBar(Static):
96
110
  )
97
111
 
98
112
  if self.show_elapsed and self._start_time:
99
- elapsed = datetime.now(tz=timezone.utc) - self._start_time
100
- elapsed_secs = int(elapsed.total_seconds())
101
- 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}")
102
117
 
103
118
  if self.show_countdown and self.countdown_minutes > 0 and self._start_time:
104
119
  total_secs = self.countdown_minutes * 60
105
- elapsed = datetime.now(tz=timezone.utc) - self._start_time
106
- remaining = total_secs - int(elapsed.total_seconds())
120
+ elapsed_secs = self._get_elapsed_seconds()
121
+ remaining = total_secs - elapsed_secs
107
122
  clock_parts.append(f"-{format_time(remaining)}")
108
123
 
109
- 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.
134
+
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
110
139
 
111
- # Combine with spacing
112
- if clock:
113
- return f" {progress}{reveal} {clock} "
114
- return f" {progress}{reveal} "
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)
115
187
 
116
188
  def reset_timer(self) -> None:
117
189
  """Reset the elapsed timer."""
118
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
119
207
  self.refresh()
120
208
 
121
209
  def toggle_clock(self) -> None:
@@ -160,6 +248,14 @@ class StatusBar(Static):
160
248
  """React to reveal total changes."""
161
249
  self.refresh()
162
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
+
163
259
 
164
260
  # Keep these for backwards compatibility and separate use
165
261
  class ProgressBar(Static):
File without changes
File without changes
File without changes