prezo 2026.1.2__py3-none-any.whl → 2026.1.3__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/layout.py CHANGED
@@ -11,6 +11,13 @@ Supports Pandoc-style fenced div syntax:
11
11
  :::
12
12
  :::
13
13
 
14
+ Additional layout blocks:
15
+ ::: center - Horizontally centered content
16
+ ::: right - Right-aligned content
17
+ ::: spacer [n] - Vertical space (default 1 line)
18
+ ::: box [title] - Bordered panel with optional title
19
+ ::: divider [style] - Horizontal rule (single/double/thick/dashed)
20
+
14
21
  """
15
22
 
16
23
  from __future__ import annotations
@@ -20,9 +27,12 @@ from dataclasses import dataclass, field
20
27
  from io import StringIO
21
28
  from typing import TYPE_CHECKING, Literal
22
29
 
30
+ from rich.cells import cell_len
23
31
  from rich.console import Console, ConsoleOptions, Group, RenderResult
24
32
  from rich.markdown import Markdown
25
33
  from rich.measure import Measurement
34
+ from rich.panel import Panel
35
+ from rich.rule import Rule
26
36
  from rich.text import Text
27
37
 
28
38
  if TYPE_CHECKING:
@@ -33,22 +43,30 @@ if TYPE_CHECKING:
33
43
  # -----------------------------------------------------------------------------
34
44
 
35
45
 
46
+ BlockType = Literal[
47
+ "plain", "columns", "column", "center", "right", "spacer", "box", "divider"
48
+ ]
49
+
50
+
36
51
  @dataclass
37
52
  class LayoutBlock:
38
53
  """A block of content with layout information."""
39
54
 
40
- type: Literal["plain", "columns", "column", "center"]
55
+ type: BlockType
41
56
  content: str = "" # Raw markdown content (for leaf blocks)
42
57
  children: list[LayoutBlock] = field(default_factory=list)
43
58
  width_percent: int = 0 # For column blocks (0 = auto/equal)
59
+ title: str = "" # For box blocks
60
+ style: str = "" # For divider blocks (single/double/thick/dashed)
44
61
 
45
62
 
46
63
  # -----------------------------------------------------------------------------
47
64
  # Parser
48
65
  # -----------------------------------------------------------------------------
49
66
 
50
- # Pattern for opening fenced div: ::: type [width]
51
- OPEN_PATTERN = re.compile(r"^:::\s*(\w+)(?:\s+(\d+))?\s*$")
67
+ # Pattern for opening fenced div: ::: type [arg]
68
+ # arg can be: a number (width %), a quoted string (title), or a word (style)
69
+ OPEN_PATTERN = re.compile(r'^:::\s*(\w+)(?:\s+"([^"]+)"|\s+(\S+))?\s*$')
52
70
  # Pattern for closing fenced div: :::
53
71
  CLOSE_PATTERN = re.compile(r"^:::\s*$")
54
72
 
@@ -76,10 +94,14 @@ def parse_layout(content: str) -> list[LayoutBlock]:
76
94
 
77
95
  if match:
78
96
  block_type = match.group(1).lower()
79
- width = int(match.group(2)) if match.group(2) else 0
97
+ # Group 2 is quoted string, Group 3 is unquoted arg
98
+ quoted_arg = match.group(2) # For "title"
99
+ unquoted_arg = match.group(3) # For width or style
80
100
 
81
101
  # Find matching close and nested content
82
- block, end_idx = _parse_fenced_block(lines, i, block_type, width)
102
+ block, end_idx = _parse_fenced_block(
103
+ lines, i, block_type, quoted_arg, unquoted_arg
104
+ )
83
105
  if block:
84
106
  blocks.append(block)
85
107
  i = end_idx + 1
@@ -104,8 +126,59 @@ def parse_layout(content: str) -> list[LayoutBlock]:
104
126
  return blocks
105
127
 
106
128
 
129
+ def _create_block(
130
+ block_type: str,
131
+ inner_content: str,
132
+ quoted_arg: str | None,
133
+ unquoted_arg: str | None,
134
+ ) -> LayoutBlock:
135
+ """Create a LayoutBlock from parsed fenced div content.
136
+
137
+ Args:
138
+ block_type: The type from ::: type.
139
+ inner_content: Content inside the fenced div.
140
+ quoted_arg: Quoted argument (e.g., title for box).
141
+ unquoted_arg: Unquoted argument (e.g., width or style).
142
+
143
+ Returns:
144
+ A LayoutBlock of the appropriate type.
145
+
146
+ """
147
+ content = inner_content.strip()
148
+ width = int(unquoted_arg) if unquoted_arg and unquoted_arg.isdigit() else 0
149
+
150
+ # Use a dispatch table for simple content blocks
151
+ simple_types = {"center", "right", "plain"}
152
+
153
+ if block_type == "columns":
154
+ block = LayoutBlock(type="columns")
155
+ block.children = parse_layout(inner_content)
156
+ elif block_type == "column":
157
+ block = LayoutBlock(type="column", content=content, width_percent=width)
158
+ elif block_type == "spacer":
159
+ lines_count = width if width > 0 else 1
160
+ block = LayoutBlock(type="spacer", width_percent=lines_count)
161
+ elif block_type == "box":
162
+ title = quoted_arg or unquoted_arg or ""
163
+ block = LayoutBlock(type="box", content=content, title=title)
164
+ elif block_type == "divider":
165
+ style = unquoted_arg if unquoted_arg else "single"
166
+ block = LayoutBlock(type="divider", style=style)
167
+ elif block_type in simple_types:
168
+ block = LayoutBlock(type=block_type, content=content)
169
+ else:
170
+ # Unknown block type - treat as plain
171
+ block = LayoutBlock(type="plain", content=content)
172
+
173
+ return block
174
+
175
+
107
176
  def _parse_fenced_block(
108
- lines: list[str], start: int, block_type: str, width: int
177
+ lines: list[str],
178
+ start: int,
179
+ block_type: str,
180
+ quoted_arg: str | None,
181
+ unquoted_arg: str | None,
109
182
  ) -> tuple[LayoutBlock | None, int]:
110
183
  """Parse a fenced div block starting at the given line.
111
184
 
@@ -113,7 +186,8 @@ def _parse_fenced_block(
113
186
  lines: All lines of content.
114
187
  start: Starting line index (the opening :::).
115
188
  block_type: The type from ::: type.
116
- width: Width percentage if specified.
189
+ quoted_arg: Quoted argument (e.g., title for box).
190
+ unquoted_arg: Unquoted argument (e.g., width or style).
117
191
 
118
192
  Returns:
119
193
  Tuple of (LayoutBlock or None, end line index).
@@ -143,28 +217,8 @@ def _parse_fenced_block(
143
217
  return None, start
144
218
 
145
219
  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
220
+ block = _create_block(block_type, inner_content, quoted_arg, unquoted_arg)
221
+ return block, i
168
222
 
169
223
 
170
224
  def has_layout_blocks(content: str) -> bool:
@@ -303,10 +357,17 @@ class ColumnsRenderable:
303
357
  file=StringIO(),
304
358
  )
305
359
 
306
- # Render markdown content
360
+ # Render content - check for nested layout blocks
307
361
  if column.content:
308
- md = Markdown(column.content)
309
- col_console.print(md)
362
+ if has_layout_blocks(column.content):
363
+ # Parse and render nested layout blocks
364
+ blocks = parse_layout(column.content)
365
+ renderable = render_layout(blocks)
366
+ col_console.print(renderable)
367
+ else:
368
+ # Plain markdown
369
+ md = Markdown(column.content)
370
+ col_console.print(md)
310
371
 
311
372
  # Get rendered lines
312
373
  output = col_console.export_text(styles=True)
@@ -405,6 +466,170 @@ class CenterRenderable:
405
466
  return Measurement(1, options.max_width)
406
467
 
407
468
 
469
+ class RightRenderable:
470
+ """Rich renderable that right-aligns content."""
471
+
472
+ def __init__(self, content: str) -> None:
473
+ """Initialize right-align renderable.
474
+
475
+ Args:
476
+ content: Markdown content to right-align.
477
+
478
+ """
479
+ self.content = content
480
+
481
+ def __rich_console__(
482
+ self, console: Console, options: ConsoleOptions
483
+ ) -> RenderResult:
484
+ """Render right-aligned content."""
485
+ yield Text("")
486
+ md = Markdown(self.content, justify="right")
487
+ yield md
488
+ yield Text("")
489
+
490
+ def __rich_measure__(
491
+ self, console: Console, options: ConsoleOptions
492
+ ) -> Measurement:
493
+ """Return the measurement of this renderable."""
494
+ return Measurement(1, options.max_width)
495
+
496
+
497
+ class SpacerRenderable:
498
+ """Rich renderable that creates vertical space."""
499
+
500
+ def __init__(self, lines: int = 1) -> None:
501
+ """Initialize spacer renderable.
502
+
503
+ Args:
504
+ lines: Number of blank lines to insert.
505
+
506
+ """
507
+ self.lines = max(1, lines)
508
+
509
+ def __rich_console__(
510
+ self, console: Console, options: ConsoleOptions
511
+ ) -> RenderResult:
512
+ """Render vertical space."""
513
+ for _ in range(self.lines):
514
+ yield Text("")
515
+
516
+ def __rich_measure__(
517
+ self, console: Console, options: ConsoleOptions
518
+ ) -> Measurement:
519
+ """Return the measurement of this renderable."""
520
+ return Measurement(0, 0)
521
+
522
+
523
+ class BoxRenderable:
524
+ """Rich renderable that displays content in a bordered panel."""
525
+
526
+ def __init__(self, content: str, title: str = "") -> None:
527
+ """Initialize box renderable.
528
+
529
+ Args:
530
+ content: Markdown content to display in the box.
531
+ title: Optional title for the box.
532
+
533
+ """
534
+ self.content = content
535
+ self.title = title
536
+
537
+ def __rich_console__(
538
+ self, console: Console, options: ConsoleOptions
539
+ ) -> RenderResult:
540
+ """Render content in a bordered panel."""
541
+ yield Text("")
542
+ md = Markdown(self.content)
543
+ panel = Panel(md, title=self.title if self.title else None)
544
+ yield panel
545
+ yield Text("")
546
+
547
+ def __rich_measure__(
548
+ self, console: Console, options: ConsoleOptions
549
+ ) -> Measurement:
550
+ """Return the measurement of this renderable."""
551
+ return Measurement(1, options.max_width)
552
+
553
+
554
+ # Divider style characters
555
+ DIVIDER_STYLES = {
556
+ "single": "─",
557
+ "double": "═",
558
+ "thick": "━",
559
+ "dashed": "╌",
560
+ }
561
+
562
+
563
+ class DividerRenderable:
564
+ """Rich renderable that displays a horizontal rule."""
565
+
566
+ def __init__(self, style: str = "single") -> None:
567
+ """Initialize divider renderable.
568
+
569
+ Args:
570
+ style: Style of the divider (single, double, thick, dashed).
571
+
572
+ """
573
+ self.style = style if style in DIVIDER_STYLES else "single"
574
+
575
+ def __rich_console__(
576
+ self, console: Console, options: ConsoleOptions
577
+ ) -> RenderResult:
578
+ """Render horizontal rule."""
579
+ yield Text("")
580
+ char = DIVIDER_STYLES[self.style]
581
+ yield Rule(characters=char)
582
+ yield Text("")
583
+
584
+ def __rich_measure__(
585
+ self, console: Console, options: ConsoleOptions
586
+ ) -> Measurement:
587
+ """Return the measurement of this renderable."""
588
+ return Measurement(1, options.max_width)
589
+
590
+
591
+ def _render_block(block: LayoutBlock) -> list[RenderableType]:
592
+ """Render a single block to Rich renderables.
593
+
594
+ Args:
595
+ block: A LayoutBlock to render.
596
+
597
+ Returns:
598
+ List of Rich renderables for this block.
599
+
600
+ """
601
+ if block.type == "columns":
602
+ result: list[RenderableType] = []
603
+ columns = [c for c in block.children if c.type == "column"]
604
+ if columns:
605
+ result.append(ColumnsRenderable(columns))
606
+ # Also render any non-column children (plain text between columns)
607
+ for child in block.children:
608
+ if child.type == "plain":
609
+ result.append(Markdown(child.content))
610
+ return result
611
+
612
+ if block.type == "spacer":
613
+ return [SpacerRenderable(block.width_percent)]
614
+
615
+ if block.type == "box":
616
+ return [BoxRenderable(block.content, block.title)]
617
+
618
+ if block.type == "divider":
619
+ return [DividerRenderable(block.style)]
620
+
621
+ # Simple content blocks: plain, center, right, column
622
+ renderable_map: dict[str, type] = {
623
+ "center": CenterRenderable,
624
+ "right": RightRenderable,
625
+ }
626
+ if block.type in renderable_map:
627
+ return [renderable_map[block.type](block.content)]
628
+
629
+ # Default: plain markdown (for "plain" and standalone "column")
630
+ return [Markdown(block.content)]
631
+
632
+
408
633
  def render_layout(
409
634
  blocks: list[LayoutBlock],
410
635
  ) -> RenderableType:
@@ -420,23 +645,7 @@ def render_layout(
420
645
  renderables: list[RenderableType] = []
421
646
 
422
647
  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))
648
+ renderables.extend(_render_block(block))
440
649
 
441
650
  if len(renderables) == 1:
442
651
  return renderables[0]
@@ -452,13 +661,20 @@ _ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
452
661
 
453
662
 
454
663
  def _visible_length(text: str) -> int:
455
- """Calculate visible length of text, excluding ANSI codes.
664
+ """Calculate visible cell width of text, excluding ANSI codes.
665
+
666
+ Uses Rich's cell_len for proper Unicode width handling:
667
+ - Regular ASCII characters = 1 cell
668
+ - Wide characters (CJK, emoji) = 2 cells
669
+ - Zero-width characters = 0 cells
456
670
 
457
671
  Args:
458
672
  text: Text possibly containing ANSI escape codes.
459
673
 
460
674
  Returns:
461
- Visible character count.
675
+ Visible cell width (terminal columns).
462
676
 
463
677
  """
464
- return len(_ANSI_PATTERN.sub("", text))
678
+ # Strip ANSI codes first, then calculate cell width
679
+ clean_text = _ANSI_PATTERN.sub("", text)
680
+ return cell_len(clean_text)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: prezo
3
- Version: 2026.1.2
3
+ Version: 2026.1.3
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>
@@ -1,7 +1,12 @@
1
- prezo/__init__.py,sha256=blmZoW_bPndFLVT4UZzrfAp8IaGuvmFhtNGUs0nHpFQ,6434
2
- prezo/app.py,sha256=Uk4Wn8Q9_lJVMwHhGvBLrZ_ARCvtwUpX9X_KDyJ-OXs,32144
3
- prezo/config.py,sha256=byBBFHZU3fkq3dRfg5h4zG_eihbi7lHZkriSv-g-ogY,6672
4
- prezo/export.py,sha256=BNqN3a9XXlh22ELJAXnxw_fp0VMas_t-7DWLqyg1kbc,24430
1
+ prezo/__init__.py,sha256=RDpFh0F3DGdMB08n7G3HM-c14JAoqvElq4DyXLSPDQg,6740
2
+ prezo/app.py,sha256=3RPSx56hjyyG55ueNWHvuUNe-KkQ3ZzidNgBRc3I2WQ,31713
3
+ prezo/config.py,sha256=DLHUQkThxhmYXQE1AgpWkPvtNlDwOxQRSNjRrfJJEew,6646
4
+ prezo/export/__init__.py,sha256=jdf4Xu71aUKPBXUt8k8TEUzgMee4bfEh71iJGZXtVKE,1010
5
+ prezo/export/common.py,sha256=W9Gn2_wjd3EPuenAECrs6pqsJEWFBJHtGs8y6-4VEKQ,2451
6
+ prezo/export/html.py,sha256=GLRjTvZUEmvee6F2NlU3sn94H47PqLTLpaC3Ab_kxo8,8787
7
+ prezo/export/images.py,sha256=TNTGIYNRTxwVjbHqZXYjIj0gouV_Dy1_DtenMlxsquE,7410
8
+ prezo/export/pdf.py,sha256=9rzybk8o1vEu_JwvKB2abzrYoKfpEA8IP5pEFJHF5WM,13942
9
+ prezo/export/svg.py,sha256=gxhRXpLiKcVr4NSF8nwaid74p8f9dvj4RwXwvMP0EbU,5698
5
10
  prezo/images/__init__.py,sha256=xrWSR3z0SXYpLtjIvR2VOMxiJGkxEsls5-EOs9GecFA,324
6
11
  prezo/images/ascii.py,sha256=aNz02jN4rkDw0WzmkGDrAGw1R1dY5QGREvIIPI6jwow,7613
7
12
  prezo/images/base.py,sha256=STuS57AVSJ2lzwyn0QrIceGaSd2IWEiLGN-elT3u3AM,2749
@@ -11,7 +16,7 @@ prezo/images/kitty.py,sha256=mWR-tIE_WDP5BjOkQydPpxWBBGNaZL8PkMICesWQid8,10883
11
16
  prezo/images/overlay.py,sha256=lWIAvINxZrKembtB0gzWWaUoedNt7beFU4OhErfwWaw,9600
12
17
  prezo/images/processor.py,sha256=zMcfwltecup_AX2FhUIlPdO7c87a9jw7P9tLTIkr54U,4069
13
18
  prezo/images/sixel.py,sha256=2IeKDiMsWU1Tn3HYI3PC972ygxKGqpfz6tnhQcM_sVM,5604
14
- prezo/layout.py,sha256=dQHp-9TV-vCp1yqt3WeiS1mrghAh9HPw3aap5DKOeoE,13551
19
+ prezo/layout.py,sha256=xy-UaZvU2ZDe0H7XjMCDb30tog4LRdZKB5OUh_r0aqo,19929
15
20
  prezo/parser.py,sha256=f1eJtr9xVnxa35jgQ3GvKwNa6TvaXrIKQWgBB_geVAI,15058
16
21
  prezo/screens/__init__.py,sha256=xHG9jNJz4vi1tpneSEVlD0D9I0M2U4gAGk6-R9xbUf4,508
17
22
  prezo/screens/base.py,sha256=2n6Uj8evfIbcpn4AVYNG5iM_k7rIJ3Vwmor_xrQPU9E,2057
@@ -28,7 +33,7 @@ prezo/widgets/image_display.py,sha256=8IKncaoC2iWebmJQp_QomF7UVgRxD4WThOshN1Nht2
28
33
  prezo/widgets/slide_button.py,sha256=g5mvtCZSorTIZp_PXgHYeYeeCSNFy0pW3K7iDlZu7yA,2012
29
34
  prezo/widgets/slide_content.py,sha256=AO3doIuPBSo5vec_d1xE5F8YMJWxzOq5IFO7gSYSWNw,2300
30
35
  prezo/widgets/status_bar.py,sha256=Wcun71kg2Q4s5aduPwTvS4kDHZj5p-zDmD7Cx3_ZFP4,8136
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,,
36
+ prezo-2026.1.3.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
37
+ prezo-2026.1.3.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
38
+ prezo-2026.1.3.dist-info/METADATA,sha256=mv3i3_l0Rq03cmGDQzwBeZ9FiUIJ6enFmJNSvm83v2I,5320
39
+ prezo-2026.1.3.dist-info/RECORD,,