prezo 2026.1.4__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 +23 -0
- prezo/app.py +28 -1
- prezo/config.py +1 -0
- prezo/export/svg.py +74 -71
- prezo/parser.py +8 -0
- prezo/screens/help.py +4 -0
- prezo/widgets/status_bar.py +110 -14
- {prezo-2026.1.4.dist-info → prezo-2026.2.1.dist-info}/METADATA +6 -1
- {prezo-2026.1.4.dist-info → prezo-2026.2.1.dist-info}/RECORD +11 -11
- {prezo-2026.1.4.dist-info → prezo-2026.2.1.dist-info}/WHEEL +0 -0
- {prezo-2026.1.4.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="?",
|
|
@@ -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
|
)
|
prezo/app.py
CHANGED
|
@@ -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,
|
|
1225
|
+
presentation_path,
|
|
1226
|
+
watch=watch,
|
|
1227
|
+
config=config,
|
|
1228
|
+
incremental=incremental,
|
|
1229
|
+
time_budget=time_budget,
|
|
1203
1230
|
)
|
|
1204
1231
|
app.run()
|
prezo/config.py
CHANGED
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/parser.py
CHANGED
|
@@ -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"):
|
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
|
|
|
@@ -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) ->
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
remaining = total_secs -
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
if
|
|
113
|
-
return
|
|
114
|
-
|
|
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):
|
|
@@ -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
|
|
@@ -17,12 +17,12 @@ 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
19
|
prezo/layout.py,sha256=FN-nMX902jx9MA7TnySbPskmnyWAiziv_xBvTTYrFd0,22463
|
|
20
|
-
prezo/parser.py,sha256=
|
|
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
|