prezo 2026.1.1__py3-none-any.whl → 2026.1.2__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/app.py CHANGED
@@ -39,7 +39,7 @@ from .screens import (
39
39
  )
40
40
  from .terminal import ImageCapability, detect_image_capability
41
41
  from .themes import get_next_theme, get_theme
42
- from .widgets import ImageDisplay, StatusBar
42
+ from .widgets import ImageDisplay, SlideContent, StatusBar
43
43
 
44
44
  WELCOME_MESSAGE = """\
45
45
  # Welcome to Prezo
@@ -389,7 +389,7 @@ class PrezoApp(App):
389
389
  yield ImageDisplay(id="slide-image")
390
390
  # Text container
391
391
  with VerticalScroll(id="slide-container"):
392
- yield Markdown("", id="slide-content")
392
+ yield SlideContent("", id="slide-content")
393
393
  with Vertical(id="notes-panel"):
394
394
  yield Static("Notes", id="notes-title")
395
395
  yield Markdown("", id="notes-content")
@@ -519,7 +519,7 @@ class PrezoApp(App):
519
519
  recent_section = _format_recent_files(self.state.recent_files)
520
520
  if recent_section:
521
521
  welcome += recent_section
522
- self.query_one("#slide-content", Markdown).update(welcome)
522
+ self.query_one("#slide-content", SlideContent).set_content(welcome)
523
523
  status = self.query_one("#status-bar", StatusBar)
524
524
  status.current = 0
525
525
  status.total = 1
@@ -591,7 +591,9 @@ class PrezoApp(App):
591
591
  image_widget.clear()
592
592
 
593
593
  # Use cleaned content (bg images already removed by parser)
594
- self.query_one("#slide-content", Markdown).update(slide.content.strip())
594
+ self.query_one("#slide-content", SlideContent).set_content(
595
+ slide.content.strip()
596
+ )
595
597
 
596
598
  container = self.query_one("#slide-container", VerticalScroll)
597
599
  container.scroll_home(animate=False)
prezo/export.py CHANGED
@@ -16,7 +16,8 @@ from rich.panel import Panel
16
16
  from rich.style import Style
17
17
  from rich.text import Text
18
18
 
19
- from .parser import parse_presentation
19
+ from .layout import has_layout_blocks, parse_layout, render_layout
20
+ from .parser import clean_marp_directives, extract_notes, parse_presentation
20
21
  from .themes import get_theme
21
22
 
22
23
  # Export result types
@@ -48,7 +49,7 @@ SVG_FORMAT_NO_CHROME = """\
48
49
  }}
49
50
 
50
51
  .{unique_id}-matrix {{
51
- font-family: Fira Code, monospace;
52
+ font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace;
52
53
  font-size: {char_height}px;
53
54
  line-height: {line_height}px;
54
55
  font-variant-east-asian: full-width;
@@ -113,13 +114,17 @@ def render_slide_to_svg(
113
114
  # Base style for the entire slide (background color)
114
115
  base_style = Style(color=theme.text, bgcolor=theme.background)
115
116
 
116
- # Render the markdown content
117
- md = Markdown(content)
117
+ # Render the content (with layout support)
118
+ if has_layout_blocks(content):
119
+ blocks = parse_layout(content)
120
+ slide_content = render_layout(blocks)
121
+ else:
122
+ slide_content = Markdown(content)
118
123
 
119
124
  # Create a panel with the slide content (height - 2 for status bar and padding)
120
125
  panel_height = height - 2
121
126
  panel = Panel(
122
- md,
127
+ slide_content,
123
128
  title=f"[{theme.text_muted}]Slide {slide_num + 1}/{total_slides}[/]",
124
129
  title_align="right",
125
130
  border_style=Style(color=theme.primary),
@@ -149,6 +154,13 @@ def render_slide_to_svg(
149
154
  else:
150
155
  svg = console.export_svg(code_format=SVG_FORMAT_NO_CHROME)
151
156
 
157
+ # Add emoji font fallbacks to font-family declarations
158
+ # Rich only specifies "Fira Code, monospace" which lacks emoji glyphs
159
+ svg = svg.replace(
160
+ "font-family: Fira Code, monospace",
161
+ 'font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
162
+ )
163
+
152
164
  # Add background color to SVG (Rich doesn't set it by default)
153
165
  # Insert a rect element right after the opening svg tag
154
166
  bg_rect = f'<rect width="100%" height="100%" fill="{theme.background}"/>'
@@ -386,6 +398,16 @@ HTML_TEMPLATE = """\
386
398
  max-width: 100%;
387
399
  height: auto;
388
400
  }}
401
+ /* Multi-column layouts */
402
+ .columns {{
403
+ display: flex;
404
+ gap: 2rem;
405
+ align-items: flex-start;
406
+ }}
407
+ .columns > div {{
408
+ flex: 1;
409
+ min-width: 0;
410
+ }}
389
411
  .notes {{
390
412
  margin-top: 2rem;
391
413
  padding: 1rem;
@@ -500,7 +522,10 @@ def export_to_html(
500
522
  # Render each slide
501
523
  slides_html = []
502
524
  for i, slide in enumerate(presentation.slides):
503
- content_html = render_slide_to_html(slide.content)
525
+ # Use raw_content and clean with keep_divs=True to preserve column layouts
526
+ slide_content, _ = extract_notes(slide.raw_content)
527
+ cleaned_content = clean_marp_directives(slide_content, keep_divs=True)
528
+ content_html = render_slide_to_html(cleaned_content)
504
529
 
505
530
  # Handle notes
506
531
  notes_html = ""
prezo/layout.py ADDED
@@ -0,0 +1,464 @@
1
+ """Layout parsing and rendering for multi-column slides.
2
+
3
+ Supports Pandoc-style fenced div syntax:
4
+
5
+ ::: columns
6
+ ::: column
7
+ Left content
8
+ :::
9
+ ::: column
10
+ Right content
11
+ :::
12
+ :::
13
+
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from io import StringIO
21
+ from typing import TYPE_CHECKING, Literal
22
+
23
+ from rich.console import Console, ConsoleOptions, Group, RenderResult
24
+ from rich.markdown import Markdown
25
+ from rich.measure import Measurement
26
+ from rich.text import Text
27
+
28
+ if TYPE_CHECKING:
29
+ from rich.console import RenderableType
30
+
31
+ # -----------------------------------------------------------------------------
32
+ # Data Types
33
+ # -----------------------------------------------------------------------------
34
+
35
+
36
+ @dataclass
37
+ class LayoutBlock:
38
+ """A block of content with layout information."""
39
+
40
+ type: Literal["plain", "columns", "column", "center"]
41
+ content: str = "" # Raw markdown content (for leaf blocks)
42
+ children: list[LayoutBlock] = field(default_factory=list)
43
+ width_percent: int = 0 # For column blocks (0 = auto/equal)
44
+
45
+
46
+ # -----------------------------------------------------------------------------
47
+ # Parser
48
+ # -----------------------------------------------------------------------------
49
+
50
+ # Pattern for opening fenced div: ::: type [width]
51
+ OPEN_PATTERN = re.compile(r"^:::\s*(\w+)(?:\s+(\d+))?\s*$")
52
+ # Pattern for closing fenced div: :::
53
+ CLOSE_PATTERN = re.compile(r"^:::\s*$")
54
+
55
+
56
+ def parse_layout(content: str) -> list[LayoutBlock]:
57
+ """Parse markdown content into layout blocks.
58
+
59
+ Detects Pandoc-style fenced divs and builds a tree of LayoutBlocks.
60
+ Content outside fenced divs becomes plain blocks.
61
+
62
+ Args:
63
+ content: Markdown content possibly containing fenced divs.
64
+
65
+ Returns:
66
+ List of LayoutBlock objects representing the content structure.
67
+
68
+ """
69
+ lines = content.split("\n")
70
+ blocks: list[LayoutBlock] = []
71
+ i = 0
72
+
73
+ while i < len(lines):
74
+ line = lines[i]
75
+ match = OPEN_PATTERN.match(line)
76
+
77
+ if match:
78
+ block_type = match.group(1).lower()
79
+ width = int(match.group(2)) if match.group(2) else 0
80
+
81
+ # Find matching close and nested content
82
+ block, end_idx = _parse_fenced_block(lines, i, block_type, width)
83
+ if block:
84
+ blocks.append(block)
85
+ i = end_idx + 1
86
+ continue
87
+ # Unclosed block - treat as plain text, skip the opening line
88
+ i += 1
89
+ continue
90
+
91
+ # Not a fenced div - accumulate plain content
92
+ plain_lines = []
93
+ while i < len(lines):
94
+ if OPEN_PATTERN.match(lines[i]):
95
+ break
96
+ plain_lines.append(lines[i])
97
+ i += 1
98
+
99
+ if plain_lines:
100
+ plain_content = "\n".join(plain_lines).strip()
101
+ if plain_content:
102
+ blocks.append(LayoutBlock(type="plain", content=plain_content))
103
+
104
+ return blocks
105
+
106
+
107
+ def _parse_fenced_block(
108
+ lines: list[str], start: int, block_type: str, width: int
109
+ ) -> tuple[LayoutBlock | None, int]:
110
+ """Parse a fenced div block starting at the given line.
111
+
112
+ Args:
113
+ lines: All lines of content.
114
+ start: Starting line index (the opening :::).
115
+ block_type: The type from ::: type.
116
+ width: Width percentage if specified.
117
+
118
+ Returns:
119
+ Tuple of (LayoutBlock or None, end line index).
120
+
121
+ """
122
+ depth = 1
123
+ i = start + 1
124
+ content_lines: list[str] = []
125
+
126
+ while i < len(lines) and depth > 0:
127
+ line = lines[i]
128
+
129
+ if CLOSE_PATTERN.match(line):
130
+ depth -= 1
131
+ if depth == 0:
132
+ break
133
+ content_lines.append(line)
134
+ elif OPEN_PATTERN.match(line):
135
+ depth += 1
136
+ content_lines.append(line)
137
+ else:
138
+ content_lines.append(line)
139
+ i += 1
140
+
141
+ if depth != 0:
142
+ # Unclosed block - treat as plain text
143
+ return None, start
144
+
145
+ inner_content = "\n".join(content_lines)
146
+
147
+ # For columns/column types, parse children
148
+ if block_type in ("columns", "column", "center"):
149
+ block = LayoutBlock(
150
+ type=block_type, # type: ignore[arg-type]
151
+ width_percent=width,
152
+ )
153
+
154
+ if block_type == "columns":
155
+ # Parse children (should be column blocks)
156
+ block.children = parse_layout(inner_content)
157
+ elif block_type == "column":
158
+ # Column contains markdown content, but might have nested structure
159
+ # For now, treat as plain content
160
+ block.content = inner_content.strip()
161
+ elif block_type == "center":
162
+ block.content = inner_content.strip()
163
+
164
+ return block, i
165
+
166
+ # Unknown block type - treat content as plain
167
+ return LayoutBlock(type="plain", content=inner_content.strip()), i
168
+
169
+
170
+ def has_layout_blocks(content: str) -> bool:
171
+ """Check if content contains any layout directives.
172
+
173
+ Quick check to avoid parsing overhead for simple slides.
174
+
175
+ Args:
176
+ content: Markdown content to check.
177
+
178
+ Returns:
179
+ True if content contains ::: directives.
180
+
181
+ """
182
+ return ":::" in content
183
+
184
+
185
+ # -----------------------------------------------------------------------------
186
+ # Renderer
187
+ # -----------------------------------------------------------------------------
188
+
189
+
190
+ class ColumnsRenderable:
191
+ """Rich renderable that displays columns side-by-side."""
192
+
193
+ def __init__(
194
+ self,
195
+ columns: list[LayoutBlock],
196
+ gap: int = 2,
197
+ ) -> None:
198
+ """Initialize columns renderable.
199
+
200
+ Args:
201
+ columns: List of column LayoutBlocks.
202
+ gap: Number of spaces between columns.
203
+
204
+ """
205
+ self.columns = columns
206
+ self.gap = gap
207
+
208
+ def __rich_console__(
209
+ self, console: Console, options: ConsoleOptions
210
+ ) -> RenderResult:
211
+ """Render columns side-by-side."""
212
+ if not self.columns:
213
+ return
214
+
215
+ # Blank line before columns
216
+ yield Text("")
217
+
218
+ max_width = options.max_width
219
+ num_cols = len(self.columns)
220
+
221
+ # Calculate column widths
222
+ widths = self._calculate_widths(max_width, num_cols)
223
+
224
+ # Render each column to lines
225
+ column_outputs: list[list[str]] = []
226
+ for col, width in zip(self.columns, widths, strict=False):
227
+ lines = self._render_column(col, width, console)
228
+ column_outputs.append(lines)
229
+
230
+ # Merge columns side-by-side
231
+ merged = self._merge_columns(column_outputs, widths)
232
+
233
+ for line in merged:
234
+ yield Text.from_ansi(line)
235
+
236
+ # Blank line after columns
237
+ yield Text("")
238
+
239
+ def _calculate_widths(self, total_width: int, num_cols: int) -> list[int]:
240
+ """Calculate width for each column.
241
+
242
+ Args:
243
+ total_width: Total available width.
244
+ num_cols: Number of columns.
245
+
246
+ Returns:
247
+ List of widths for each column.
248
+
249
+ """
250
+ # Account for gaps between columns
251
+ total_gap = self.gap * (num_cols - 1)
252
+ available = total_width - total_gap
253
+
254
+ # Check if any columns have explicit widths
255
+ explicit_widths = [c.width_percent for c in self.columns]
256
+ total_explicit = sum(w for w in explicit_widths if w > 0)
257
+
258
+ if total_explicit > 0:
259
+ # Use explicit percentages
260
+ widths = []
261
+ remaining = available
262
+ auto_count = sum(1 for w in explicit_widths if w == 0)
263
+
264
+ for w in explicit_widths:
265
+ if w > 0:
266
+ col_width = max(1, (available * w) // 100)
267
+ widths.append(col_width)
268
+ remaining -= col_width
269
+ else:
270
+ widths.append(0) # Placeholder
271
+
272
+ # Distribute remaining to auto columns
273
+ if auto_count > 0:
274
+ auto_width = remaining // auto_count
275
+ widths = [w if w > 0 else auto_width for w in widths]
276
+ else:
277
+ # Equal distribution
278
+ col_width = available // num_cols
279
+ widths = [col_width] * num_cols
280
+
281
+ return widths
282
+
283
+ def _render_column(
284
+ self, column: LayoutBlock, width: int, console: Console
285
+ ) -> list[str]:
286
+ """Render a single column to a list of lines.
287
+
288
+ Args:
289
+ column: The column LayoutBlock.
290
+ width: Width in characters.
291
+ console: Rich console for rendering.
292
+
293
+ Returns:
294
+ List of rendered lines (with ANSI codes).
295
+
296
+ """
297
+ # Create a console with fixed width for rendering
298
+ col_console = Console(
299
+ width=width,
300
+ force_terminal=True,
301
+ color_system=console.color_system,
302
+ record=True,
303
+ file=StringIO(),
304
+ )
305
+
306
+ # Render markdown content
307
+ if column.content:
308
+ md = Markdown(column.content)
309
+ col_console.print(md)
310
+
311
+ # Get rendered lines
312
+ output = col_console.export_text(styles=True)
313
+ lines = output.split("\n")
314
+
315
+ # Ensure each line is padded to column width
316
+ # Note: This is tricky with ANSI codes. For now, we'll do basic padding.
317
+ padded = []
318
+ for line in lines:
319
+ # Strip trailing whitespace but preserve ANSI
320
+ stripped = line.rstrip()
321
+ padded.append(stripped)
322
+
323
+ return padded
324
+
325
+ def _merge_columns(
326
+ self, column_outputs: list[list[str]], widths: list[int]
327
+ ) -> list[str]:
328
+ """Merge column outputs side-by-side.
329
+
330
+ Args:
331
+ column_outputs: List of line lists for each column.
332
+ widths: Width of each column.
333
+
334
+ Returns:
335
+ Merged lines.
336
+
337
+ """
338
+ if not column_outputs:
339
+ return []
340
+
341
+ # Find max height
342
+ max_height = max(len(col) for col in column_outputs)
343
+
344
+ # Pad shorter columns
345
+ for col in column_outputs:
346
+ while len(col) < max_height:
347
+ col.append("")
348
+
349
+ # Merge line by line
350
+ result = []
351
+ gap_str = " " * self.gap
352
+
353
+ for row_idx in range(max_height):
354
+ parts = []
355
+ for col_idx, col in enumerate(column_outputs):
356
+ line = col[row_idx] if row_idx < len(col) else ""
357
+ # Pad to column width (accounting for ANSI codes)
358
+ visible_len = _visible_length(line)
359
+ padding = widths[col_idx] - visible_len
360
+ if padding > 0:
361
+ line = line + " " * padding
362
+ parts.append(line)
363
+
364
+ result.append(gap_str.join(parts))
365
+
366
+ return result
367
+
368
+ def __rich_measure__(
369
+ self, console: Console, options: ConsoleOptions
370
+ ) -> Measurement:
371
+ """Return the measurement of this renderable."""
372
+ return Measurement(1, options.max_width)
373
+
374
+
375
+ class CenterRenderable:
376
+ """Rich renderable that centers content horizontally."""
377
+
378
+ def __init__(self, content: str) -> None:
379
+ """Initialize center renderable.
380
+
381
+ Args:
382
+ content: Markdown content to center.
383
+
384
+ """
385
+ self.content = content
386
+
387
+ def __rich_console__(
388
+ self, console: Console, options: ConsoleOptions
389
+ ) -> RenderResult:
390
+ """Render centered content."""
391
+ # Blank line before centered content
392
+ yield Text("")
393
+
394
+ # Use Markdown with center justification
395
+ md = Markdown(self.content, justify="center")
396
+ yield md
397
+
398
+ # Blank line after centered content
399
+ yield Text("")
400
+
401
+ def __rich_measure__(
402
+ self, console: Console, options: ConsoleOptions
403
+ ) -> Measurement:
404
+ """Return the measurement of this renderable."""
405
+ return Measurement(1, options.max_width)
406
+
407
+
408
+ def render_layout(
409
+ blocks: list[LayoutBlock],
410
+ ) -> RenderableType:
411
+ """Render layout blocks to a Rich renderable.
412
+
413
+ Args:
414
+ blocks: List of LayoutBlocks from parse_layout().
415
+
416
+ Returns:
417
+ Rich renderable representing the layout.
418
+
419
+ """
420
+ renderables: list[RenderableType] = []
421
+
422
+ for block in blocks:
423
+ match block.type:
424
+ case "plain":
425
+ renderables.append(Markdown(block.content))
426
+ case "columns":
427
+ # Filter to only column children
428
+ columns = [c for c in block.children if c.type == "column"]
429
+ if columns:
430
+ renderables.append(ColumnsRenderable(columns))
431
+ # Also render any non-column children (plain text between columns)
432
+ for child in block.children:
433
+ if child.type == "plain":
434
+ renderables.append(Markdown(child.content))
435
+ case "center":
436
+ renderables.append(CenterRenderable(block.content))
437
+ case "column":
438
+ # Standalone column (shouldn't happen normally)
439
+ renderables.append(Markdown(block.content))
440
+
441
+ if len(renderables) == 1:
442
+ return renderables[0]
443
+ return Group(*renderables)
444
+
445
+
446
+ # -----------------------------------------------------------------------------
447
+ # Utilities
448
+ # -----------------------------------------------------------------------------
449
+
450
+ # ANSI escape sequence pattern
451
+ _ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
452
+
453
+
454
+ def _visible_length(text: str) -> int:
455
+ """Calculate visible length of text, excluding ANSI codes.
456
+
457
+ Args:
458
+ text: Text possibly containing ANSI escape codes.
459
+
460
+ Returns:
461
+ Visible character count.
462
+
463
+ """
464
+ return len(_ANSI_PATTERN.sub("", text))
prezo/parser.py CHANGED
@@ -346,13 +346,19 @@ def _parse_marp_image_directive(alt_text: str) -> _ImageDirectives:
346
346
  return result
347
347
 
348
348
 
349
- def clean_marp_directives(content: str) -> str:
349
+ def clean_marp_directives(content: str, *, keep_divs: bool = False) -> str:
350
350
  """Remove MARP-specific directives that don't render in TUI.
351
351
 
352
352
  Cleans up:
353
353
  - MARP HTML comments (<!-- _class: ... -->, <!-- _header: ... -->, etc.)
354
354
  - MARP image directives (![bg ...])
355
355
  - Empty HTML divs with only styling
356
+ - All HTML divs (unless keep_divs=True for HTML export)
357
+
358
+ Args:
359
+ content: Slide content to clean.
360
+ keep_divs: If True, preserve structural divs (for HTML export).
361
+
356
362
  """
357
363
  # Remove MARP directive comments
358
364
  content = re.sub(r"<!--\s*_\w+:.*?-->\s*\n?", "", content)
@@ -363,9 +369,10 @@ def clean_marp_directives(content: str) -> str:
363
369
  # Remove empty divs with only style attributes
364
370
  content = re.sub(r'<div[^>]*style="[^"]*"[^>]*>\s*</div>\s*\n?', "", content)
365
371
 
366
- # Remove inline HTML divs (keep the content)
367
- content = re.sub(r"<div[^>]*>\s*\n?", "", content)
368
- content = re.sub(r"\s*</div>", "", content)
372
+ if not keep_divs:
373
+ # Remove inline HTML divs (keep the content) - for TUI display
374
+ content = re.sub(r"<div[^>]*>\s*\n?", "", content)
375
+ content = re.sub(r"\s*</div>", "", content)
369
376
 
370
377
  # Clean up multiple blank lines
371
378
  return re.sub(r"\n{3,}", "\n\n", content)
prezo/widgets/__init__.py CHANGED
@@ -4,6 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  from .image_display import ImageDisplay
6
6
  from .slide_button import SlideButton
7
+ from .slide_content import SlideContent
7
8
  from .status_bar import ClockDisplay, ProgressBar, StatusBar
8
9
 
9
- __all__ = ["ClockDisplay", "ImageDisplay", "ProgressBar", "SlideButton", "StatusBar"]
10
+ __all__ = [
11
+ "ClockDisplay",
12
+ "ImageDisplay",
13
+ "ProgressBar",
14
+ "SlideButton",
15
+ "SlideContent",
16
+ "StatusBar",
17
+ ]
@@ -0,0 +1,81 @@
1
+ """Slide content widget with layout support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.markdown import Markdown as RichMarkdown
6
+ from textual.widgets import Static
7
+
8
+ from prezo.layout import has_layout_blocks, parse_layout, render_layout
9
+
10
+
11
+ class SlideContent(Static):
12
+ """Widget that renders slide content with optional layout support.
13
+
14
+ Handles both plain markdown and Pandoc-style fenced div layouts:
15
+ - Plain markdown is rendered using Rich's Markdown
16
+ - Layout blocks (columns, center) are rendered using the layout module
17
+
18
+ Inherits from Static to properly handle Rich renderable display.
19
+ """
20
+
21
+ DEFAULT_CSS = """
22
+ SlideContent {
23
+ width: 100%;
24
+ height: auto;
25
+ }
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ content: str = "",
31
+ *,
32
+ name: str | None = None,
33
+ id: str | None = None,
34
+ classes: str | None = None,
35
+ ) -> None:
36
+ """Initialize the slide content widget.
37
+
38
+ Args:
39
+ content: Markdown content to display.
40
+ name: Widget name.
41
+ id: Widget ID.
42
+ classes: CSS classes.
43
+
44
+ """
45
+ # Initialize Static with the rendered content
46
+ super().__init__("", name=name, id=id, classes=classes)
47
+ self._raw_content = content
48
+ if content:
49
+ self._update_renderable()
50
+
51
+ @property
52
+ def raw_content(self) -> str:
53
+ """Get the current raw markdown content."""
54
+ return self._raw_content
55
+
56
+ def set_content(self, content: str) -> None:
57
+ """Set the markdown content and refresh the widget.
58
+
59
+ Args:
60
+ content: New markdown content to display.
61
+
62
+ """
63
+ self._raw_content = content
64
+ self._update_renderable()
65
+
66
+ def _update_renderable(self) -> None:
67
+ """Update the internal renderable based on content."""
68
+ if not self._raw_content:
69
+ super().update("")
70
+ return
71
+
72
+ # Check for layout directives
73
+ if has_layout_blocks(self._raw_content):
74
+ blocks = parse_layout(self._raw_content)
75
+ renderable = render_layout(blocks)
76
+ else:
77
+ # Plain markdown
78
+ renderable = RichMarkdown(self._raw_content)
79
+
80
+ # Use Static's update to set the renderable
81
+ super().update(renderable)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: prezo
3
- Version: 2026.1.1
3
+ Version: 2026.1.2
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>
@@ -16,9 +16,10 @@ A TUI-based presentation tool for the terminal, built with [Textual](https://tex
16
16
 
17
17
  Display presentations written in Markdown using conventions similar to those of [MARP](https://marp.app/) or [Deckset](https://www.deckset.com/).
18
18
 
19
- ## Features (v0.3)
19
+ ## Features
20
20
 
21
21
  - **Markdown presentations** - MARP/Deckset format with `---` slide separators
22
+ - **Column layouts** - Pandoc-style fenced divs for multi-column slides (`::: columns`)
22
23
  - **Live reload** - Auto-refresh when file changes (1s polling)
23
24
  - **Keyboard navigation** - Vim-style keys, arrow keys, and more
24
25
  - **Slide overview** - Grid view for quick navigation (`o`)
@@ -130,12 +131,32 @@ Presenter notes go here (after ???)
130
131
 
131
132
  # Third Slide
132
133
 
134
+ ::: columns
135
+ ::: column
136
+ **Left Column**
137
+ - Point A
138
+ - Point B
139
+ :::
140
+
141
+ ::: column
142
+ **Right Column**
143
+ - Point C
144
+ - Point D
145
+ :::
146
+ :::
147
+
148
+ ---
149
+
150
+ # Fourth Slide
151
+
133
152
  <!-- notes: Alternative notes syntax -->
134
153
 
135
- More content...
154
+ ::: center
155
+ **Centered content**
156
+ :::
136
157
  ```
137
158
 
138
- See the [Writing Presentations in Markdown](docs/tutorial.md) tutorial for a complete guide on creating presentations, including images, presenter notes, and configuration directives.
159
+ See the [Writing Presentations in Markdown](docs/tutorial.md) tutorial for a complete guide on creating presentations, including column layouts, images, presenter notes, and configuration directives.
139
160
 
140
161
  ## Themes
141
162
 
@@ -1,7 +1,7 @@
1
1
  prezo/__init__.py,sha256=blmZoW_bPndFLVT4UZzrfAp8IaGuvmFhtNGUs0nHpFQ,6434
2
- prezo/app.py,sha256=5K1tcinaTSI-X24bqV7W7fuwC0Wtw6IH_Gfy2RI3WMo,32086
2
+ prezo/app.py,sha256=Uk4Wn8Q9_lJVMwHhGvBLrZ_ARCvtwUpX9X_KDyJ-OXs,32144
3
3
  prezo/config.py,sha256=byBBFHZU3fkq3dRfg5h4zG_eihbi7lHZkriSv-g-ogY,6672
4
- prezo/export.py,sha256=1npeBV3m9_eTGhYHG7G-ekDGIRX4vNBgtt2RwpjLydc,23324
4
+ prezo/export.py,sha256=BNqN3a9XXlh22ELJAXnxw_fp0VMas_t-7DWLqyg1kbc,24430
5
5
  prezo/images/__init__.py,sha256=xrWSR3z0SXYpLtjIvR2VOMxiJGkxEsls5-EOs9GecFA,324
6
6
  prezo/images/ascii.py,sha256=aNz02jN4rkDw0WzmkGDrAGw1R1dY5QGREvIIPI6jwow,7613
7
7
  prezo/images/base.py,sha256=STuS57AVSJ2lzwyn0QrIceGaSd2IWEiLGN-elT3u3AM,2749
@@ -11,7 +11,8 @@ prezo/images/kitty.py,sha256=mWR-tIE_WDP5BjOkQydPpxWBBGNaZL8PkMICesWQid8,10883
11
11
  prezo/images/overlay.py,sha256=lWIAvINxZrKembtB0gzWWaUoedNt7beFU4OhErfwWaw,9600
12
12
  prezo/images/processor.py,sha256=zMcfwltecup_AX2FhUIlPdO7c87a9jw7P9tLTIkr54U,4069
13
13
  prezo/images/sixel.py,sha256=2IeKDiMsWU1Tn3HYI3PC972ygxKGqpfz6tnhQcM_sVM,5604
14
- prezo/parser.py,sha256=bD2MecHm7EssHd5LB2Bw6JuUqbjWPztWUu2meYwsyIQ,14793
14
+ prezo/layout.py,sha256=dQHp-9TV-vCp1yqt3WeiS1mrghAh9HPw3aap5DKOeoE,13551
15
+ prezo/parser.py,sha256=f1eJtr9xVnxa35jgQ3GvKwNa6TvaXrIKQWgBB_geVAI,15058
15
16
  prezo/screens/__init__.py,sha256=xHG9jNJz4vi1tpneSEVlD0D9I0M2U4gAGk6-R9xbUf4,508
16
17
  prezo/screens/base.py,sha256=2n6Uj8evfIbcpn4AVYNG5iM_k7rIJ3Vwmor_xrQPU9E,2057
17
18
  prezo/screens/blackout.py,sha256=wPSdD9lgu8ykAIQjU1OesnmjQoQEn9BdC4TEpABYxW4,1640
@@ -22,11 +23,12 @@ prezo/screens/search.py,sha256=3YG9WLGEIKW3YHpM0K1lgwhuqBveXd8ZoQZ178_zGd4,7809
22
23
  prezo/screens/toc.py,sha256=8WYb5nbgP9agY-hUTATxLU4X1uka_bc2MN86hFW4aRg,8241
23
24
  prezo/terminal.py,sha256=Z3DuuighY-qfF6GWH_AkR5RnAc5Gj3LsPS266VNj7Pk,3638
24
25
  prezo/themes.py,sha256=3keUgheOsNGjS0uCjRv7az9sVSnrz5tc-jZ58YNB7tg,3070
25
- prezo/widgets/__init__.py,sha256=UeTHBgPDvqTkK5tTsPXhdJXP3qZefnltKtUtvJBx9m0,295
26
+ prezo/widgets/__init__.py,sha256=6qAbVVWG2fb4DLv0EzMQ-Qbi74SviXIo-7D7DyDwnrI,378
26
27
  prezo/widgets/image_display.py,sha256=8IKncaoC2iWebmJQp_QomF7UVgRxD4WThOshN1Nht2M,3361
27
28
  prezo/widgets/slide_button.py,sha256=g5mvtCZSorTIZp_PXgHYeYeeCSNFy0pW3K7iDlZu7yA,2012
29
+ prezo/widgets/slide_content.py,sha256=AO3doIuPBSo5vec_d1xE5F8YMJWxzOq5IFO7gSYSWNw,2300
28
30
  prezo/widgets/status_bar.py,sha256=Wcun71kg2Q4s5aduPwTvS4kDHZj5p-zDmD7Cx3_ZFP4,8136
29
- prezo-2026.1.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
30
- prezo-2026.1.1.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
31
- prezo-2026.1.1.dist-info/METADATA,sha256=b1ymsfoTcOMGVvcoTbb3OSucstuN2QmPz-te-beEjSM,5061
32
- prezo-2026.1.1.dist-info/RECORD,,
31
+ prezo-2026.1.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
32
+ prezo-2026.1.2.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
33
+ prezo-2026.1.2.dist-info/METADATA,sha256=EVYgqxUj2L3lvUNia9MKC6RjKQXFAaijrnfSD7WRwDg,5320
34
+ prezo-2026.1.2.dist-info/RECORD,,