prezo 2026.1.2__tar.gz → 2026.1.4__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.
- {prezo-2026.1.2 → prezo-2026.1.4}/PKG-INFO +1 -1
- {prezo-2026.1.2 → prezo-2026.1.4}/pyproject.toml +5 -3
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/__init__.py +20 -1
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/app.py +295 -43
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/config.py +2 -1
- prezo-2026.1.4/src/prezo/export/__init__.py +36 -0
- prezo-2026.1.4/src/prezo/export/common.py +77 -0
- prezo-2026.1.4/src/prezo/export/html.py +340 -0
- prezo-2026.1.4/src/prezo/export/images.py +261 -0
- prezo-2026.1.4/src/prezo/export/pdf.py +497 -0
- prezo-2026.1.4/src/prezo/export/svg.py +170 -0
- prezo-2026.1.4/src/prezo/layout.py +771 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/parser.py +29 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/widgets/status_bar.py +20 -2
- prezo-2026.1.2/src/prezo/export.py +0 -860
- prezo-2026.1.2/src/prezo/layout.py +0 -464
- {prezo-2026.1.2 → prezo-2026.1.4}/README.md +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/__init__.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/ascii.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/base.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/chafa.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/iterm.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/kitty.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/overlay.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/processor.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/images/sixel.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/__init__.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/base.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/blackout.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/goto.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/help.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/overview.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/search.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/screens/toc.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/terminal.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/themes.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/widgets/__init__.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/widgets/image_display.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/widgets/slide_button.py +0 -0
- {prezo-2026.1.2 → prezo-2026.1.4}/src/prezo/widgets/slide_content.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "prezo"
|
|
3
|
-
version = "2026.1.
|
|
3
|
+
version = "2026.1.4"
|
|
4
4
|
description = "A TUI-based presentation tool for the terminal, built with Textual."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -18,14 +18,16 @@ prezo = "prezo:main"
|
|
|
18
18
|
|
|
19
19
|
[dependency-groups]
|
|
20
20
|
dev = [
|
|
21
|
+
"abilian-devtools>=0.8.0",
|
|
22
|
+
# Testing
|
|
21
23
|
"pytest>=8.0.0",
|
|
22
24
|
"pytest-asyncio>=1.3.0",
|
|
23
|
-
|
|
25
|
+
# Typechecking
|
|
24
26
|
"ty>=0.0.2",
|
|
27
|
+
"types-markdown>=3.10.0.20251106",
|
|
25
28
|
# See below
|
|
26
29
|
"cairosvg>=2.7.0",
|
|
27
30
|
"pypdf>=4.0.0",
|
|
28
|
-
"types-markdown>=3.10.0.20251106",
|
|
29
31
|
]
|
|
30
32
|
export = [
|
|
31
33
|
"cairosvg>=2.7.0",
|
|
@@ -128,6 +128,13 @@ def main() -> None:
|
|
|
128
128
|
action="store_true",
|
|
129
129
|
help="Export without window decorations (for printing)",
|
|
130
130
|
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--pdf-backend",
|
|
133
|
+
metavar="BACKEND",
|
|
134
|
+
choices=["auto", "chrome", "inkscape", "cairosvg"],
|
|
135
|
+
default="auto",
|
|
136
|
+
help="PDF conversion backend (auto, chrome, inkscape, cairosvg). Default: auto",
|
|
137
|
+
)
|
|
131
138
|
parser.add_argument(
|
|
132
139
|
"--scale",
|
|
133
140
|
metavar="FACTOR",
|
|
@@ -152,6 +159,12 @@ def main() -> None:
|
|
|
152
159
|
choices=["auto", "kitty", "sixel", "iterm", "ascii", "none"],
|
|
153
160
|
help="Image rendering mode (auto, kitty, sixel, iterm, ascii, none)",
|
|
154
161
|
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"-I",
|
|
164
|
+
"--incremental",
|
|
165
|
+
action="store_true",
|
|
166
|
+
help="Display lists incrementally, one item at a time (like Pandoc)",
|
|
167
|
+
)
|
|
155
168
|
|
|
156
169
|
args = parser.parse_args()
|
|
157
170
|
|
|
@@ -218,6 +231,7 @@ def main() -> None:
|
|
|
218
231
|
width=width,
|
|
219
232
|
height=height,
|
|
220
233
|
chrome=not args.no_chrome,
|
|
234
|
+
pdf_backend=args.pdf_backend,
|
|
221
235
|
),
|
|
222
236
|
)
|
|
223
237
|
else:
|
|
@@ -228,4 +242,9 @@ def main() -> None:
|
|
|
228
242
|
if args.file:
|
|
229
243
|
file_path = _validate_file(Path(args.file))
|
|
230
244
|
|
|
231
|
-
run_app(
|
|
245
|
+
run_app(
|
|
246
|
+
file_path,
|
|
247
|
+
watch=not args.no_watch,
|
|
248
|
+
config=config,
|
|
249
|
+
incremental=args.incremental,
|
|
250
|
+
)
|
|
@@ -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
|
|
@@ -109,6 +110,121 @@ def _format_recent_files(recent_files: list[str], max_files: int = 5) -> str:
|
|
|
109
110
|
return "\n".join(lines)
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
# -----------------------------------------------------------------------------
|
|
114
|
+
# Incremental List Helpers
|
|
115
|
+
# -----------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
# Pattern matching markdown list items (unordered and ordered)
|
|
118
|
+
_LIST_ITEM_PATTERN = re.compile(r"^(\s*)([-*+]|\d+\.)\s+")
|
|
119
|
+
|
|
120
|
+
# Pattern matching layout directive markers (:::)
|
|
121
|
+
_LAYOUT_MARKER_PATTERN = re.compile(r"^\s*:::")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def count_list_items(content: str) -> int:
|
|
125
|
+
"""Count the number of top-level list items in markdown content.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
content: Markdown content to analyze.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Number of top-level list items found.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
count = 0
|
|
135
|
+
for line in content.split("\n"):
|
|
136
|
+
match = _LIST_ITEM_PATTERN.match(line)
|
|
137
|
+
if match:
|
|
138
|
+
# Only count top-level items (no leading whitespace)
|
|
139
|
+
indent = match.group(1)
|
|
140
|
+
if not indent:
|
|
141
|
+
count += 1
|
|
142
|
+
return count
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Braille Pattern Blank - invisible character with width, behaves like text for layout
|
|
146
|
+
_INVISIBLE_CHAR = "\u2800"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _make_placeholder(text: str) -> str:
|
|
150
|
+
"""Create an invisible placeholder that matches the visual width of text.
|
|
151
|
+
|
|
152
|
+
Uses Braille Pattern Blank characters which are invisible but have
|
|
153
|
+
width and wrap like normal text.
|
|
154
|
+
"""
|
|
155
|
+
return _INVISIBLE_CHAR * len(text) if text else _INVISIBLE_CHAR
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def filter_list_items(content: str, max_items: int) -> str:
|
|
159
|
+
"""Filter content to show only the first N list items.
|
|
160
|
+
|
|
161
|
+
Preserves layout directive markers (:::) and other structural elements.
|
|
162
|
+
Hidden items are replaced with placeholder text of the same length
|
|
163
|
+
to maintain visual height when text wraps.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
content: Markdown content to filter.
|
|
167
|
+
max_items: Maximum number of top-level list items to show.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Filtered content with only the first N list items visible.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
if max_items < 0:
|
|
174
|
+
return content # Show all
|
|
175
|
+
|
|
176
|
+
lines = content.split("\n")
|
|
177
|
+
result_lines = []
|
|
178
|
+
item_count = 0
|
|
179
|
+
in_hidden_item = False
|
|
180
|
+
|
|
181
|
+
for line in lines:
|
|
182
|
+
# Always preserve layout markers (:::)
|
|
183
|
+
if _LAYOUT_MARKER_PATTERN.match(line):
|
|
184
|
+
result_lines.append(line)
|
|
185
|
+
# Reset hidden state when entering/exiting a block
|
|
186
|
+
in_hidden_item = False
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
match = _LIST_ITEM_PATTERN.match(line)
|
|
190
|
+
|
|
191
|
+
if match:
|
|
192
|
+
indent = match.group(1) # Leading whitespace
|
|
193
|
+
marker = match.group(2) # List marker (-, *, +, 1.)
|
|
194
|
+
text_start = match.end()
|
|
195
|
+
text = line[text_start:] # The actual text content
|
|
196
|
+
|
|
197
|
+
if len(indent) == 0:
|
|
198
|
+
# Top-level item
|
|
199
|
+
item_count += 1
|
|
200
|
+
if item_count <= max_items:
|
|
201
|
+
result_lines.append(line)
|
|
202
|
+
in_hidden_item = False
|
|
203
|
+
else:
|
|
204
|
+
# Replace with same-length placeholder
|
|
205
|
+
placeholder = _make_placeholder(text)
|
|
206
|
+
result_lines.append(f"{indent}{marker} {placeholder}")
|
|
207
|
+
in_hidden_item = True
|
|
208
|
+
elif in_hidden_item:
|
|
209
|
+
# Nested item under hidden parent - also hide
|
|
210
|
+
placeholder = _make_placeholder(text)
|
|
211
|
+
result_lines.append(f"{indent}{marker} {placeholder}")
|
|
212
|
+
else:
|
|
213
|
+
# Nested item - show if parent is visible
|
|
214
|
+
result_lines.append(line)
|
|
215
|
+
elif in_hidden_item:
|
|
216
|
+
# Content continuation of hidden item - preserve length
|
|
217
|
+
stripped = line.lstrip()
|
|
218
|
+
leading = line[: len(line) - len(stripped)]
|
|
219
|
+
placeholder = _make_placeholder(stripped)
|
|
220
|
+
result_lines.append(f"{leading}{placeholder}")
|
|
221
|
+
else:
|
|
222
|
+
# Non-list line (could be continuation or other content)
|
|
223
|
+
result_lines.append(line)
|
|
224
|
+
|
|
225
|
+
return "\n".join(result_lines)
|
|
226
|
+
|
|
227
|
+
|
|
112
228
|
class PrezoCommands(Provider):
|
|
113
229
|
"""Command provider for Prezo actions."""
|
|
114
230
|
|
|
@@ -199,7 +315,7 @@ class PrezoApp(App):
|
|
|
199
315
|
|
|
200
316
|
ENABLE_COMMAND_PALETTE = True
|
|
201
317
|
COMMAND_PALETTE_BINDING = "ctrl+p"
|
|
202
|
-
COMMANDS: ClassVar[set[type[Provider]]] = {PrezoCommands}
|
|
318
|
+
COMMANDS: ClassVar[set[type[Provider]]] = {PrezoCommands}
|
|
203
319
|
|
|
204
320
|
CSS = """
|
|
205
321
|
Screen {
|
|
@@ -342,6 +458,7 @@ class PrezoApp(App):
|
|
|
342
458
|
current_slide: reactive[int] = reactive(0)
|
|
343
459
|
notes_visible: reactive[bool] = reactive(False)
|
|
344
460
|
app_theme: reactive[str] = reactive("dark")
|
|
461
|
+
reveal_index: reactive[int] = reactive(-1) # -1 = show all, 0+ = show up to index
|
|
345
462
|
|
|
346
463
|
TITLE = "Prezo"
|
|
347
464
|
|
|
@@ -351,6 +468,7 @@ class PrezoApp(App):
|
|
|
351
468
|
*,
|
|
352
469
|
watch: bool | None = None,
|
|
353
470
|
config: Config | None = None,
|
|
471
|
+
incremental: bool = False,
|
|
354
472
|
) -> None:
|
|
355
473
|
"""Initialize the Prezo application.
|
|
356
474
|
|
|
@@ -358,6 +476,7 @@ class PrezoApp(App):
|
|
|
358
476
|
presentation_path: Path to the Markdown presentation file.
|
|
359
477
|
watch: Whether to enable file watching for live reload.
|
|
360
478
|
config: Optional config override. Uses global config if None.
|
|
479
|
+
incremental: Whether to display lists incrementally (-I flag).
|
|
361
480
|
|
|
362
481
|
"""
|
|
363
482
|
super().__init__()
|
|
@@ -367,6 +486,9 @@ class PrezoApp(App):
|
|
|
367
486
|
self.presentation_path = Path(presentation_path) if presentation_path else None
|
|
368
487
|
self.presentation: Presentation | None = None
|
|
369
488
|
|
|
489
|
+
# Incremental lists: CLI flag overrides config
|
|
490
|
+
self.incremental_cli = incremental
|
|
491
|
+
|
|
370
492
|
# Use config for watch if not explicitly set
|
|
371
493
|
if watch is None:
|
|
372
494
|
self.watch_enabled = self.config.behavior.auto_reload
|
|
@@ -436,11 +558,20 @@ class PrezoApp(App):
|
|
|
436
558
|
return
|
|
437
559
|
|
|
438
560
|
old_slide = self.current_slide
|
|
561
|
+
old_reveal = self.reveal_index
|
|
439
562
|
self.presentation = parse_presentation(self.presentation_path)
|
|
440
563
|
|
|
441
564
|
if old_slide >= self.presentation.total_slides:
|
|
442
|
-
|
|
565
|
+
target_slide = max(0, self.presentation.total_slides - 1)
|
|
566
|
+
self._init_reveal_for_slide(target_slide, show_all=False)
|
|
567
|
+
self.current_slide = target_slide
|
|
443
568
|
else:
|
|
569
|
+
# Preserve reveal position if still valid
|
|
570
|
+
list_count = self._get_list_count(old_slide)
|
|
571
|
+
if self._is_incremental_enabled(old_slide) and list_count > 0:
|
|
572
|
+
self.reveal_index = max(0, min(old_reveal, list_count - 1))
|
|
573
|
+
else:
|
|
574
|
+
self.reveal_index = -1
|
|
444
575
|
self._update_display()
|
|
445
576
|
|
|
446
577
|
self.notify("Presentation reloaded", timeout=2)
|
|
@@ -453,12 +584,16 @@ class PrezoApp(App):
|
|
|
453
584
|
# Restore last position or start at 0
|
|
454
585
|
abs_path = str(self.presentation_path.absolute())
|
|
455
586
|
last_pos = self.state.get_position(abs_path)
|
|
456
|
-
if last_pos < self.presentation.total_slides
|
|
457
|
-
|
|
587
|
+
target_slide = last_pos if last_pos < self.presentation.total_slides else 0
|
|
588
|
+
|
|
589
|
+
# Set the slide - the watcher will initialize reveal state
|
|
590
|
+
if self.current_slide == target_slide:
|
|
591
|
+
# Watcher won't fire, so initialize manually
|
|
592
|
+
self._init_reveal_for_slide(target_slide, show_all=False)
|
|
593
|
+
self._update_display()
|
|
458
594
|
else:
|
|
459
|
-
self.current_slide =
|
|
595
|
+
self.current_slide = target_slide
|
|
460
596
|
|
|
461
|
-
self._update_display()
|
|
462
597
|
self._update_progress_bar()
|
|
463
598
|
|
|
464
599
|
if self.presentation.title:
|
|
@@ -479,6 +614,81 @@ class PrezoApp(App):
|
|
|
479
614
|
self._apply_timer_config(status_bar)
|
|
480
615
|
status_bar.reset_timer()
|
|
481
616
|
|
|
617
|
+
def _is_incremental_enabled(self, slide_index: int | None = None) -> bool:
|
|
618
|
+
"""Check if incremental mode is enabled for a slide.
|
|
619
|
+
|
|
620
|
+
Priority: per-slide directive > CLI flag > config > presentation directive
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
slide_index: Slide index to check. Uses current_slide if None.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
True if incremental lists should be enabled.
|
|
627
|
+
|
|
628
|
+
"""
|
|
629
|
+
if not self.presentation or not self.presentation.slides:
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
idx = slide_index if slide_index is not None else self.current_slide
|
|
633
|
+
if idx < 0 or idx >= len(self.presentation.slides):
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
slide = self.presentation.slides[idx]
|
|
637
|
+
|
|
638
|
+
# Per-slide directive takes highest priority
|
|
639
|
+
if slide.incremental is not None:
|
|
640
|
+
return slide.incremental
|
|
641
|
+
|
|
642
|
+
# CLI flag overrides config and presentation directives
|
|
643
|
+
if self.incremental_cli:
|
|
644
|
+
return True
|
|
645
|
+
|
|
646
|
+
# Presentation directive (from <!-- prezo --> block)
|
|
647
|
+
if self.presentation.directives.incremental_lists is not None:
|
|
648
|
+
return self.presentation.directives.incremental_lists
|
|
649
|
+
|
|
650
|
+
# Fall back to config
|
|
651
|
+
return self.config.behavior.incremental_lists
|
|
652
|
+
|
|
653
|
+
def _get_list_count(self, slide_index: int | None = None) -> int:
|
|
654
|
+
"""Get the number of top-level list items in a slide.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
slide_index: Slide index to check. Uses current_slide if None.
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Number of top-level list items.
|
|
661
|
+
|
|
662
|
+
"""
|
|
663
|
+
if not self.presentation or not self.presentation.slides:
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
idx = slide_index if slide_index is not None else self.current_slide
|
|
667
|
+
if idx < 0 or idx >= len(self.presentation.slides):
|
|
668
|
+
return 0
|
|
669
|
+
|
|
670
|
+
slide = self.presentation.slides[idx]
|
|
671
|
+
return count_list_items(slide.content)
|
|
672
|
+
|
|
673
|
+
def _init_reveal_for_slide(
|
|
674
|
+
self, slide_index: int, *, show_all: bool = False
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Initialize reveal state for a specific slide.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
slide_index: The slide to initialize for.
|
|
680
|
+
show_all: If True, reveal all items. If False, start with first item.
|
|
681
|
+
|
|
682
|
+
"""
|
|
683
|
+
if self._is_incremental_enabled(slide_index):
|
|
684
|
+
list_count = self._get_list_count(slide_index)
|
|
685
|
+
if list_count > 0:
|
|
686
|
+
self.reveal_index = (list_count - 1) if show_all else 0
|
|
687
|
+
else:
|
|
688
|
+
self.reveal_index = -1 # No list items, show all content
|
|
689
|
+
else:
|
|
690
|
+
self.reveal_index = -1 # Incremental disabled, show all
|
|
691
|
+
|
|
482
692
|
def _apply_presentation_directives(self) -> None:
|
|
483
693
|
"""Apply presentation-specific directives on top of config."""
|
|
484
694
|
if not self.presentation:
|
|
@@ -555,35 +765,27 @@ class PrezoApp(App):
|
|
|
555
765
|
image_container.add_class("visible")
|
|
556
766
|
|
|
557
767
|
# Apply layout based on MARP directive
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
image_container, before=slide_container
|
|
575
|
-
)
|
|
576
|
-
elif layout in ("background", "fit"):
|
|
577
|
-
# Background/fit images: show image full width behind/above text
|
|
578
|
-
image_container.add_class("layout-inline")
|
|
579
|
-
horizontal_container.move_child(
|
|
580
|
-
image_container, before=slide_container
|
|
581
|
-
)
|
|
768
|
+
match first_image.layout:
|
|
769
|
+
case "left":
|
|
770
|
+
image_container.add_class("layout-left")
|
|
771
|
+
horizontal_container.move_child(
|
|
772
|
+
image_container, before=slide_container
|
|
773
|
+
)
|
|
774
|
+
case "right":
|
|
775
|
+
image_container.add_class("layout-right")
|
|
776
|
+
horizontal_container.move_child(
|
|
777
|
+
image_container, after=slide_container
|
|
778
|
+
)
|
|
779
|
+
case "inline" | "background" | "fit":
|
|
780
|
+
image_container.add_class("layout-inline")
|
|
781
|
+
horizontal_container.move_child(
|
|
782
|
+
image_container, before=slide_container
|
|
783
|
+
)
|
|
582
784
|
|
|
583
785
|
# Apply dynamic width if size_percent is specified
|
|
584
786
|
default_size = 50
|
|
585
787
|
has_custom_size = first_image.size_percent != default_size
|
|
586
|
-
if has_custom_size and layout in ("left", "right"):
|
|
788
|
+
if has_custom_size and first_image.layout in ("left", "right"):
|
|
587
789
|
image_container.styles.width = f"{first_image.size_percent}%"
|
|
588
790
|
else:
|
|
589
791
|
image_container.styles.width = None # Reset to CSS default
|
|
@@ -591,9 +793,13 @@ class PrezoApp(App):
|
|
|
591
793
|
image_widget.clear()
|
|
592
794
|
|
|
593
795
|
# Use cleaned content (bg images already removed by parser)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
796
|
+
content = slide.content.strip()
|
|
797
|
+
|
|
798
|
+
# Apply incremental filtering if enabled
|
|
799
|
+
if self._is_incremental_enabled() and self.reveal_index >= 0:
|
|
800
|
+
content = filter_list_items(content, self.reveal_index + 1)
|
|
801
|
+
|
|
802
|
+
self.query_one("#slide-content", SlideContent).set_content(content)
|
|
597
803
|
|
|
598
804
|
container = self.query_one("#slide-container", VerticalScroll)
|
|
599
805
|
container.scroll_home(animate=False)
|
|
@@ -602,7 +808,7 @@ class PrezoApp(App):
|
|
|
602
808
|
self._update_notes()
|
|
603
809
|
|
|
604
810
|
def _update_progress_bar(self) -> None:
|
|
605
|
-
"""Update the progress bar."""
|
|
811
|
+
"""Update the progress bar and reveal indicator."""
|
|
606
812
|
if not self.presentation:
|
|
607
813
|
return
|
|
608
814
|
|
|
@@ -610,6 +816,19 @@ class PrezoApp(App):
|
|
|
610
816
|
status.current = self.current_slide
|
|
611
817
|
status.total = self.presentation.total_slides
|
|
612
818
|
|
|
819
|
+
# Update reveal indicator
|
|
820
|
+
if self._is_incremental_enabled():
|
|
821
|
+
list_count = self._get_list_count()
|
|
822
|
+
if list_count > 0 and self.reveal_index >= 0:
|
|
823
|
+
status.reveal_current = self.reveal_index
|
|
824
|
+
status.reveal_total = list_count
|
|
825
|
+
else:
|
|
826
|
+
status.reveal_current = -1
|
|
827
|
+
status.reveal_total = 0
|
|
828
|
+
else:
|
|
829
|
+
status.reveal_current = -1
|
|
830
|
+
status.reveal_total = 0
|
|
831
|
+
|
|
613
832
|
def _update_notes(self) -> None:
|
|
614
833
|
"""Update the notes panel content."""
|
|
615
834
|
if not self.presentation or not self.presentation.slides:
|
|
@@ -625,6 +844,9 @@ class PrezoApp(App):
|
|
|
625
844
|
|
|
626
845
|
def watch_current_slide(self, old_value: int, new_value: int) -> None:
|
|
627
846
|
"""React to slide changes."""
|
|
847
|
+
# Determine direction and initialize reveal state appropriately
|
|
848
|
+
going_back = new_value < old_value
|
|
849
|
+
self._init_reveal_for_slide(new_value, show_all=going_back)
|
|
628
850
|
self._update_display()
|
|
629
851
|
self._save_position()
|
|
630
852
|
|
|
@@ -644,15 +866,41 @@ class PrezoApp(App):
|
|
|
644
866
|
notes_panel.remove_class("visible")
|
|
645
867
|
|
|
646
868
|
def action_next_slide(self) -> None:
|
|
647
|
-
"""Go to the next slide."""
|
|
648
|
-
if
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
869
|
+
"""Go to the next slide or reveal next list item."""
|
|
870
|
+
if not self.presentation:
|
|
871
|
+
return
|
|
872
|
+
|
|
873
|
+
# Check if we should reveal next item instead of advancing slide
|
|
874
|
+
if self._is_incremental_enabled():
|
|
875
|
+
list_count = self._get_list_count()
|
|
876
|
+
if (
|
|
877
|
+
list_count > 0
|
|
878
|
+
and self.reveal_index >= 0
|
|
879
|
+
and self.reveal_index < list_count - 1
|
|
880
|
+
):
|
|
881
|
+
self.reveal_index += 1
|
|
882
|
+
self._update_display()
|
|
883
|
+
self._update_progress_bar()
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
# No more items to reveal, go to next slide
|
|
887
|
+
if self.current_slide < self.presentation.total_slides - 1:
|
|
888
|
+
# The watcher will initialize reveal_index for the new slide
|
|
652
889
|
self.current_slide += 1
|
|
653
890
|
|
|
654
891
|
def action_prev_slide(self) -> None:
|
|
655
|
-
"""Go to the previous slide."""
|
|
892
|
+
"""Go to the previous slide or hide last revealed item."""
|
|
893
|
+
if not self.presentation:
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
# Check if we should hide last item instead of going back
|
|
897
|
+
if self._is_incremental_enabled() and self.reveal_index > 0:
|
|
898
|
+
self.reveal_index -= 1
|
|
899
|
+
self._update_display()
|
|
900
|
+
self._update_progress_bar()
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
# Go to previous slide (watcher will show all items)
|
|
656
904
|
if self.current_slide > 0:
|
|
657
905
|
self.current_slide -= 1
|
|
658
906
|
|
|
@@ -740,7 +988,7 @@ class PrezoApp(App):
|
|
|
740
988
|
def watch_app_theme(self, theme_name: str) -> None:
|
|
741
989
|
"""Apply theme when it changes."""
|
|
742
990
|
# Only apply to widgets after mount (watcher fires during init)
|
|
743
|
-
if not self.is_mounted:
|
|
991
|
+
if not self.is_mounted:
|
|
744
992
|
return
|
|
745
993
|
self._apply_theme(theme_name)
|
|
746
994
|
self.notify(f"Theme: {theme_name}", timeout=1)
|
|
@@ -939,6 +1187,7 @@ def run_app(
|
|
|
939
1187
|
*,
|
|
940
1188
|
watch: bool | None = None,
|
|
941
1189
|
config: Config | None = None,
|
|
1190
|
+
incremental: bool = False,
|
|
942
1191
|
) -> None:
|
|
943
1192
|
"""Run the Prezo application.
|
|
944
1193
|
|
|
@@ -946,7 +1195,10 @@ def run_app(
|
|
|
946
1195
|
presentation_path: Path to the presentation file.
|
|
947
1196
|
watch: Whether to watch for file changes. Uses config default if None.
|
|
948
1197
|
config: Optional config override. Uses global config if None.
|
|
1198
|
+
incremental: Whether to display lists incrementally (-I flag).
|
|
949
1199
|
|
|
950
1200
|
"""
|
|
951
|
-
app = PrezoApp(
|
|
1201
|
+
app = PrezoApp(
|
|
1202
|
+
presentation_path, watch=watch, config=config, incremental=incremental
|
|
1203
|
+
)
|
|
952
1204
|
app.run()
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
try:
|
|
12
12
|
import tomllib
|
|
13
13
|
except ImportError:
|
|
14
|
-
import tomli as tomllib
|
|
14
|
+
import tomli as tomllib
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
CONFIG_DIR = Path.home() / ".config" / "prezo"
|
|
@@ -69,6 +69,7 @@ class BehaviorConfig:
|
|
|
69
69
|
|
|
70
70
|
auto_reload: bool = True
|
|
71
71
|
reload_interval: float = 1.0
|
|
72
|
+
incremental_lists: bool = False # Display lists one item at a time
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
@dataclass
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Export functionality for prezo presentations.
|
|
2
|
+
|
|
3
|
+
Exports presentations to PDF, HTML, PNG, and SVG formats.
|
|
4
|
+
|
|
5
|
+
IMPORTANT: PDF/PNG/SVG export must be a faithful image of the TUI console.
|
|
6
|
+
This requires proper monospace font loading. If Fira Code is not available,
|
|
7
|
+
alignment may be incorrect.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .common import (
|
|
13
|
+
ExportError,
|
|
14
|
+
check_font_availability,
|
|
15
|
+
print_font_warnings,
|
|
16
|
+
)
|
|
17
|
+
from .html import export_to_html, render_slide_to_html, run_html_export
|
|
18
|
+
from .images import export_slide_to_image, export_to_images, run_image_export
|
|
19
|
+
from .pdf import combine_svgs_to_pdf, export_to_pdf, run_export
|
|
20
|
+
from .svg import render_slide_to_svg
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ExportError",
|
|
24
|
+
"check_font_availability",
|
|
25
|
+
"combine_svgs_to_pdf",
|
|
26
|
+
"export_slide_to_image",
|
|
27
|
+
"export_to_html",
|
|
28
|
+
"export_to_images",
|
|
29
|
+
"export_to_pdf",
|
|
30
|
+
"print_font_warnings",
|
|
31
|
+
"render_slide_to_html",
|
|
32
|
+
"render_slide_to_svg",
|
|
33
|
+
"run_export",
|
|
34
|
+
"run_html_export",
|
|
35
|
+
"run_image_export",
|
|
36
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Common utilities and constants for export functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExportError(Exception):
|
|
11
|
+
"""Raised when an export operation fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Exit codes for CLI (only used in run_* wrapper functions)
|
|
15
|
+
EXIT_SUCCESS = 0
|
|
16
|
+
EXIT_FAILURE = 2
|
|
17
|
+
|
|
18
|
+
# Backwards compatibility aliases (deprecated, use exceptions instead)
|
|
19
|
+
EXPORT_SUCCESS = EXIT_SUCCESS
|
|
20
|
+
EXPORT_FAILED = EXIT_FAILURE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_font_availability() -> list[str]:
|
|
24
|
+
"""Check if required fonts are available on the system.
|
|
25
|
+
|
|
26
|
+
Returns a list of warning messages (empty if all fonts are available).
|
|
27
|
+
"""
|
|
28
|
+
warnings = []
|
|
29
|
+
|
|
30
|
+
# Check for fc-list (fontconfig) to query system fonts
|
|
31
|
+
fc_list_path = shutil.which("fc-list")
|
|
32
|
+
if fc_list_path:
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
[fc_list_path, ":family"],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=5,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
fonts = result.stdout.lower()
|
|
42
|
+
|
|
43
|
+
# Check for Fira Code (primary monospace font)
|
|
44
|
+
if "fira code" not in fonts and "firacode" not in fonts:
|
|
45
|
+
warnings.append(
|
|
46
|
+
"Fira Code font not found. Install it for best results:\n"
|
|
47
|
+
" macOS: brew install --cask font-fira-code\n"
|
|
48
|
+
" Ubuntu: sudo apt install fonts-firacode\n"
|
|
49
|
+
" Or download from: https://github.com/tonsky/FiraCode"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
53
|
+
# Can't check fonts, skip warning
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
# No fontconfig available (Windows or minimal system)
|
|
57
|
+
# We can't easily check fonts, so just note the requirement
|
|
58
|
+
warnings.append(
|
|
59
|
+
"Cannot verify font availability. For correct alignment, ensure "
|
|
60
|
+
"Fira Code font is installed."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return warnings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_font_warnings(warnings: list[str]) -> None:
|
|
67
|
+
"""Print font warnings to stderr."""
|
|
68
|
+
if warnings:
|
|
69
|
+
print("\n⚠️ Font Warning:", file=sys.stderr)
|
|
70
|
+
for warning in warnings:
|
|
71
|
+
for line in warning.split("\n"):
|
|
72
|
+
print(f" {line}", file=sys.stderr)
|
|
73
|
+
print(
|
|
74
|
+
"\n Without proper fonts, column alignment may be incorrect in exports.",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
print(file=sys.stderr)
|