prezo 0.3.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/screens/toc.py ADDED
@@ -0,0 +1,254 @@
1
+ """Table of Contents screen for prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import VerticalScroll
10
+ from textual.widgets import Static
11
+
12
+ from .base import ThemedModalScreen
13
+
14
+ if TYPE_CHECKING:
15
+ from textual.app import ComposeResult
16
+
17
+ from prezo.parser import Presentation
18
+
19
+
20
+ class TocEntry(Static):
21
+ """A single TOC entry."""
22
+
23
+ def __init__(
24
+ self,
25
+ slide_index: int,
26
+ title: str,
27
+ level: int,
28
+ is_current: bool = False,
29
+ **kwargs,
30
+ ) -> None:
31
+ """Initialize a TOC entry.
32
+
33
+ Args:
34
+ slide_index: Index of the slide this entry refers to.
35
+ title: Title text for the entry.
36
+ level: Heading level (1-6) for indentation.
37
+ is_current: Whether this is the current slide.
38
+ **kwargs: Additional arguments for Static widget.
39
+
40
+ """
41
+ super().__init__(**kwargs)
42
+ self.slide_index = slide_index
43
+ self.title = title
44
+ self.level = level
45
+ self.is_current = is_current
46
+
47
+ def render(self) -> str:
48
+ """Render the TOC entry with indentation and marker."""
49
+ indent = " " * (self.level - 1)
50
+ marker = "►" if self.is_current else " "
51
+ return f"{marker} {indent}{self.title} ({self.slide_index + 1})"
52
+
53
+
54
+ class TableOfContentsScreen(ThemedModalScreen[int | None]):
55
+ """Modal screen showing table of contents based on headings."""
56
+
57
+ CSS = """
58
+ TableOfContentsScreen {
59
+ align: center middle;
60
+ }
61
+
62
+ #toc-container {
63
+ width: 70%;
64
+ height: 80%;
65
+ background: $surface;
66
+ border: thick $primary;
67
+ padding: 1 2;
68
+ }
69
+
70
+ #toc-title {
71
+ width: 100%;
72
+ height: 3;
73
+ content-align: center middle;
74
+ text-style: bold;
75
+ background: $primary;
76
+ color: $text;
77
+ margin-bottom: 1;
78
+ }
79
+
80
+ #toc-list {
81
+ width: 100%;
82
+ height: 1fr;
83
+ }
84
+
85
+ .toc-entry {
86
+ width: 100%;
87
+ height: 1;
88
+ padding: 0 1;
89
+ }
90
+
91
+ .toc-entry:hover {
92
+ background: $primary-darken-2;
93
+ }
94
+
95
+ .toc-entry.selected {
96
+ background: $primary;
97
+ }
98
+
99
+ .toc-entry.current {
100
+ text-style: bold;
101
+ }
102
+
103
+ #toc-hint {
104
+ width: 100%;
105
+ text-align: center;
106
+ color: $text-muted;
107
+ margin-top: 1;
108
+ }
109
+ """
110
+
111
+ BINDINGS: ClassVar[list[BindingType]] = [
112
+ Binding("escape", "cancel", "Cancel"),
113
+ Binding("q", "cancel", "Cancel"),
114
+ Binding("enter", "select", "Select"),
115
+ Binding("space", "select", "Select", show=False),
116
+ Binding("up", "move_up", "Up", show=False),
117
+ Binding("down", "move_down", "Down", show=False),
118
+ Binding("k", "move_up", "Up", show=False),
119
+ Binding("j", "move_down", "Down", show=False),
120
+ Binding("home", "first", "First", show=False),
121
+ Binding("end", "last", "Last", show=False),
122
+ ]
123
+
124
+ def __init__(self, presentation: Presentation, current_slide: int) -> None:
125
+ """Initialize the table of contents screen.
126
+
127
+ Args:
128
+ presentation: The presentation to build TOC from.
129
+ current_slide: Index of the currently active slide.
130
+
131
+ """
132
+ super().__init__()
133
+ self.presentation = presentation
134
+ self.current_slide = current_slide
135
+ self.entries: list[tuple[int, str, int]] = [] # (slide_index, title, level)
136
+ self.selected_index = 0
137
+ self._build_toc()
138
+
139
+ def _build_toc(self) -> None:
140
+ """Build table of contents from slide headings."""
141
+ for i, slide in enumerate(self.presentation.slides):
142
+ heading = self._extract_heading(slide.content)
143
+ if heading:
144
+ title, level = heading
145
+ self.entries.append((i, title, level))
146
+
147
+ # If no headings found, create entries for all slides
148
+ if not self.entries:
149
+ for i, slide in enumerate(self.presentation.slides):
150
+ title = self._get_first_line(slide.content)
151
+ self.entries.append((i, title, 1))
152
+
153
+ # Set initial selection to current slide's entry
154
+ for idx, (slide_idx, _, _) in enumerate(self.entries):
155
+ if slide_idx >= self.current_slide:
156
+ self.selected_index = idx
157
+ break
158
+
159
+ def _extract_heading(self, content: str) -> tuple[str, int] | None:
160
+ """Extract first heading and its level from content."""
161
+ for line in content.strip().split("\n"):
162
+ line = line.strip()
163
+ match = re.match(r"^(#{1,6})\s+(.+)$", line)
164
+ if match:
165
+ level = len(match.group(1))
166
+ title = match.group(2).strip()
167
+ # Remove markdown formatting
168
+ title = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", title)
169
+ return title[:60], level
170
+ return None
171
+
172
+ def _get_first_line(self, content: str) -> str:
173
+ """Get first non-empty line of content."""
174
+ for line in content.strip().split("\n"):
175
+ line = line.strip()
176
+ if line and not line.startswith("<!--"):
177
+ return line[:60]
178
+ return "Untitled"
179
+
180
+ def compose(self) -> ComposeResult:
181
+ """Compose the table of contents layout."""
182
+ with VerticalScroll(id="toc-container"):
183
+ yield Static(" Table of Contents ", id="toc-title")
184
+ with VerticalScroll(id="toc-list"):
185
+ for idx, (slide_idx, title, level) in enumerate(self.entries):
186
+ is_current = slide_idx == self.current_slide
187
+ classes = "toc-entry"
188
+ if idx == self.selected_index:
189
+ classes += " selected"
190
+ if is_current:
191
+ classes += " current"
192
+ yield TocEntry(slide_idx, title, level, is_current, classes=classes)
193
+ yield Static("Enter to jump, Esc to cancel", id="toc-hint")
194
+
195
+ def on_mount(self) -> None:
196
+ """Scroll to the selected entry on mount."""
197
+ super().on_mount()
198
+ self._scroll_to_selected()
199
+
200
+ def _update_selection(self) -> None:
201
+ """Update visual selection."""
202
+ toc_list = self.query_one("#toc-list", VerticalScroll)
203
+ for idx, child in enumerate(toc_list.query(".toc-entry")):
204
+ if idx == self.selected_index:
205
+ child.add_class("selected")
206
+ child.scroll_visible()
207
+ else:
208
+ child.remove_class("selected")
209
+
210
+ def _scroll_to_selected(self) -> None:
211
+ """Scroll to show the selected entry."""
212
+ toc_list = self.query_one("#toc-list", VerticalScroll)
213
+ entries = list(toc_list.query(".toc-entry"))
214
+ if 0 <= self.selected_index < len(entries):
215
+ entries[self.selected_index].scroll_visible()
216
+
217
+ def action_cancel(self) -> None:
218
+ """Cancel and dismiss the TOC screen."""
219
+ self.dismiss(None)
220
+
221
+ def action_select(self) -> None:
222
+ """Select the currently highlighted TOC entry."""
223
+ if 0 <= self.selected_index < len(self.entries):
224
+ self.dismiss(self.entries[self.selected_index][0])
225
+ else:
226
+ self.dismiss(None)
227
+
228
+ def action_move_up(self) -> None:
229
+ """Move selection up in the TOC list."""
230
+ if self.selected_index > 0:
231
+ self.selected_index -= 1
232
+ self._update_selection()
233
+
234
+ def action_move_down(self) -> None:
235
+ """Move selection down in the TOC list."""
236
+ if self.selected_index < len(self.entries) - 1:
237
+ self.selected_index += 1
238
+ self._update_selection()
239
+
240
+ def action_first(self) -> None:
241
+ """Jump to first TOC entry."""
242
+ self.selected_index = 0
243
+ self._update_selection()
244
+
245
+ def action_last(self) -> None:
246
+ """Jump to last TOC entry."""
247
+ self.selected_index = len(self.entries) - 1
248
+ self._update_selection()
249
+
250
+ def on_click(self, event) -> None:
251
+ """Handle clicking on a TOC entry."""
252
+ widget = self.get_widget_at(event.screen_x, event.screen_y)
253
+ if widget and isinstance(widget, TocEntry):
254
+ self.dismiss(widget.slide_index)
prezo/terminal.py ADDED
@@ -0,0 +1,147 @@
1
+ """Terminal capability detection for Prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from enum import Enum
8
+ from functools import lru_cache
9
+
10
+
11
+ class ImageCapability(Enum):
12
+ """Terminal image rendering capabilities."""
13
+
14
+ KITTY = "kitty"
15
+ SIXEL = "sixel"
16
+ ITERM = "iterm"
17
+ ASCII = "ascii"
18
+ NONE = "none"
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def detect_image_capability() -> ImageCapability:
23
+ """Detect the best image rendering capability for the current terminal.
24
+
25
+ Returns:
26
+ The detected image capability.
27
+
28
+ """
29
+ # Check for Kitty terminal
30
+ if _is_kitty():
31
+ return ImageCapability.KITTY
32
+
33
+ # Check for iTerm2
34
+ if _is_iterm():
35
+ return ImageCapability.ITERM
36
+
37
+ # Check for Sixel support
38
+ if _has_sixel_support():
39
+ return ImageCapability.SIXEL
40
+
41
+ # Fallback to ASCII
42
+ return ImageCapability.ASCII
43
+
44
+
45
+ def _is_kitty() -> bool:
46
+ """Check if running in Kitty terminal."""
47
+ # KITTY_WINDOW_ID is set by Kitty
48
+ if os.environ.get("KITTY_WINDOW_ID"):
49
+ return True
50
+
51
+ # Check TERM
52
+ term = os.environ.get("TERM", "")
53
+ return "kitty" in term.lower()
54
+
55
+
56
+ def _is_iterm() -> bool:
57
+ """Check if running in iTerm2."""
58
+ # iTerm2 sets these environment variables
59
+ if os.environ.get("TERM_PROGRAM") == "iTerm.app":
60
+ return True
61
+
62
+ if os.environ.get("LC_TERMINAL") == "iTerm2":
63
+ return True
64
+
65
+ # Check for iTerm2 specific env var
66
+ return bool(os.environ.get("ITERM_SESSION_ID"))
67
+
68
+
69
+ def _has_sixel_support() -> bool:
70
+ """Check if terminal supports Sixel graphics.
71
+
72
+ Note: This is a heuristic check. Proper detection would require
73
+ querying the terminal with escape sequences.
74
+
75
+ """
76
+ term = os.environ.get("TERM", "")
77
+ term_program = os.environ.get("TERM_PROGRAM", "")
78
+
79
+ # Known Sixel-capable terminals
80
+ sixel_terms = ["mlterm", "xterm", "mintty", "foot"]
81
+
82
+ for t in sixel_terms:
83
+ if t in term.lower() or t in term_program.lower():
84
+ return True
85
+
86
+ # Check for explicit sixel in TERM
87
+ return "sixel" in term.lower()
88
+
89
+
90
+ def get_terminal_size() -> tuple[int, int]:
91
+ """Get terminal size in columns and rows.
92
+
93
+ Returns:
94
+ Tuple of (columns, rows).
95
+
96
+ """
97
+ try:
98
+ size = os.get_terminal_size()
99
+ return size.columns, size.lines
100
+ except OSError:
101
+ # Fallback for non-TTY
102
+ return 80, 24
103
+
104
+
105
+ def supports_unicode() -> bool:
106
+ """Check if terminal supports Unicode output."""
107
+ # Check encoding
108
+ encoding = getattr(sys.stdout, "encoding", "") or ""
109
+ if "utf" in encoding.lower():
110
+ return True
111
+
112
+ # Check LANG environment variable
113
+ lang = os.environ.get("LANG", "")
114
+ return "utf" in lang.lower()
115
+
116
+
117
+ def supports_true_color() -> bool:
118
+ """Check if terminal supports 24-bit true color."""
119
+ colorterm = os.environ.get("COLORTERM", "")
120
+ if colorterm in ("truecolor", "24bit"):
121
+ return True
122
+
123
+ term = os.environ.get("TERM", "")
124
+ if "256color" in term or "truecolor" in term:
125
+ return True
126
+
127
+ # iTerm2 and Kitty support true color
128
+ return _is_iterm() or _is_kitty()
129
+
130
+
131
+ def get_capability_summary() -> dict[str, bool | str]:
132
+ """Get a summary of terminal capabilities.
133
+
134
+ Returns:
135
+ Dictionary of capability names to values.
136
+
137
+ """
138
+ cols, rows = get_terminal_size()
139
+ return {
140
+ "image_capability": detect_image_capability().value,
141
+ "unicode": supports_unicode(),
142
+ "true_color": supports_true_color(),
143
+ "columns": cols,
144
+ "rows": rows,
145
+ "term": os.environ.get("TERM", ""),
146
+ "term_program": os.environ.get("TERM_PROGRAM", ""),
147
+ }
prezo/themes.py ADDED
@@ -0,0 +1,129 @@
1
+ """Theme definitions for Prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Theme:
10
+ """A color theme for the presentation viewer."""
11
+
12
+ name: str
13
+ primary: str
14
+ secondary: str
15
+ background: str
16
+ surface: str
17
+ text: str
18
+ text_muted: str
19
+ success: str
20
+ warning: str
21
+ error: str
22
+
23
+
24
+ # Built-in themes
25
+ THEMES: dict[str, Theme] = {
26
+ "dark": Theme(
27
+ name="dark",
28
+ primary="#0178d4",
29
+ secondary="#6f42c1",
30
+ background="#121212",
31
+ surface="#1e1e1e",
32
+ text="#e0e0e0",
33
+ text_muted="#888888",
34
+ success="#28a745",
35
+ warning="#ffc107",
36
+ error="#dc3545",
37
+ ),
38
+ "light": Theme(
39
+ name="light",
40
+ primary="#0066cc",
41
+ secondary="#6f42c1",
42
+ background="#ffffff",
43
+ surface="#f5f5f5",
44
+ text="#1a1a1a",
45
+ text_muted="#666666",
46
+ success="#28a745",
47
+ warning="#ffc107",
48
+ error="#dc3545",
49
+ ),
50
+ "dracula": Theme(
51
+ name="dracula",
52
+ primary="#bd93f9",
53
+ secondary="#ff79c6",
54
+ background="#282a36",
55
+ surface="#44475a",
56
+ text="#f8f8f2",
57
+ text_muted="#6272a4",
58
+ success="#50fa7b",
59
+ warning="#f1fa8c",
60
+ error="#ff5555",
61
+ ),
62
+ "solarized-dark": Theme(
63
+ name="solarized-dark",
64
+ primary="#268bd2",
65
+ secondary="#2aa198",
66
+ background="#002b36",
67
+ surface="#073642",
68
+ text="#839496",
69
+ text_muted="#586e75",
70
+ success="#859900",
71
+ warning="#b58900",
72
+ error="#dc322f",
73
+ ),
74
+ "nord": Theme(
75
+ name="nord",
76
+ primary="#88c0d0",
77
+ secondary="#81a1c1",
78
+ background="#2e3440",
79
+ surface="#3b4252",
80
+ text="#eceff4",
81
+ text_muted="#7b88a1",
82
+ success="#a3be8c",
83
+ warning="#ebcb8b",
84
+ error="#bf616a",
85
+ ),
86
+ "gruvbox": Theme(
87
+ name="gruvbox",
88
+ primary="#83a598",
89
+ secondary="#d3869b",
90
+ background="#282828",
91
+ surface="#3c3836",
92
+ text="#ebdbb2",
93
+ text_muted="#928374",
94
+ success="#b8bb26",
95
+ warning="#fabd2f",
96
+ error="#fb4934",
97
+ ),
98
+ }
99
+
100
+ THEME_ORDER = ["dark", "light", "dracula", "solarized-dark", "nord", "gruvbox"]
101
+
102
+
103
+ def get_theme(name: str) -> Theme:
104
+ """Get a theme by name, defaulting to 'dark'."""
105
+ return THEMES.get(name, THEMES["dark"])
106
+
107
+
108
+ def get_next_theme(current: str) -> str:
109
+ """Get the next theme name in the cycle."""
110
+ try:
111
+ idx = THEME_ORDER.index(current)
112
+ return THEME_ORDER[(idx + 1) % len(THEME_ORDER)]
113
+ except ValueError:
114
+ return THEME_ORDER[0]
115
+
116
+
117
+ def theme_to_css(theme: Theme) -> str:
118
+ """Generate Textual CSS variables from a theme."""
119
+ return f"""
120
+ $primary: {theme.primary};
121
+ $secondary: {theme.secondary};
122
+ $background: {theme.background};
123
+ $surface: {theme.surface};
124
+ $text: {theme.text};
125
+ $text-muted: {theme.text_muted};
126
+ $success: {theme.success};
127
+ $warning: {theme.warning};
128
+ $error: {theme.error};
129
+ """
@@ -0,0 +1,9 @@
1
+ """Custom widgets for Prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .image_display import ImageDisplay
6
+ from .slide_button import SlideButton
7
+ from .status_bar import ClockDisplay, ProgressBar, StatusBar
8
+
9
+ __all__ = ["ClockDisplay", "ImageDisplay", "ProgressBar", "SlideButton", "StatusBar"]
@@ -0,0 +1,117 @@
1
+ """Image display widget for Prezo.
2
+
3
+ Uses textual-image for native terminal graphics protocol support (Kitty, Sixel).
4
+ Falls back to Unicode halfcell rendering for unsupported terminals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from textual.widgets import Static
12
+ from textual_image.widget import Image as TextualImage
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ from textual.app import ComposeResult
18
+
19
+
20
+ class ImageDisplay(Static):
21
+ """Widget that displays images in the terminal.
22
+
23
+ Uses textual-image library which supports:
24
+ - Kitty Terminal Graphics Protocol (TGP)
25
+ - Sixel graphics (iTerm2, WezTerm, xterm, etc.)
26
+ - Unicode halfcell fallback for other terminals
27
+
28
+ This is a container widget that wraps textual_image.widget.Image
29
+ to provide a consistent API for Prezo.
30
+ """
31
+
32
+ DEFAULT_CSS = """
33
+ ImageDisplay {
34
+ width: 100%;
35
+ height: auto;
36
+ min-height: 10;
37
+ padding: 0;
38
+ }
39
+
40
+ ImageDisplay > Image {
41
+ width: 100%;
42
+ height: auto;
43
+ }
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ image_path: Path | str | None = None,
49
+ *,
50
+ width: int | None = None,
51
+ height: int | None = None,
52
+ name: str | None = None,
53
+ id: str | None = None,
54
+ classes: str | None = None,
55
+ ) -> None:
56
+ """Initialize the image display widget.
57
+
58
+ Args:
59
+ image_path: Path to the image file.
60
+ width: Width in characters (None = auto).
61
+ height: Height in characters (None = auto).
62
+ name: Widget name.
63
+ id: Widget ID.
64
+ classes: CSS classes.
65
+
66
+ """
67
+ super().__init__(name=name, id=id, classes=classes)
68
+ self._image_path: Path | str | None = image_path
69
+ self._width: int | None = width
70
+ self._height: int | None = height
71
+ self._image_widget: TextualImage | None = None
72
+
73
+ def compose(self) -> ComposeResult:
74
+ """Compose the image widget."""
75
+ self._image_widget = TextualImage(self._image_path)
76
+ self._apply_dimensions()
77
+ yield self._image_widget
78
+
79
+ def _apply_dimensions(self) -> None:
80
+ """Apply width/height dimensions to the image widget."""
81
+ if self._image_widget is None:
82
+ return
83
+
84
+ # Apply width if specified
85
+ if self._width is not None:
86
+ self._image_widget.styles.width = self._width
87
+ # Apply height if specified
88
+ if self._height is not None:
89
+ self._image_widget.styles.height = self._height
90
+
91
+ def set_image(
92
+ self,
93
+ path: Path | str | None,
94
+ *,
95
+ width: int | None = None,
96
+ height: int | None = None,
97
+ ) -> None:
98
+ """Set the image to display.
99
+
100
+ Args:
101
+ path: Path to the image file, or None to clear.
102
+ width: Width in characters (None = auto).
103
+ height: Height in characters (None = auto).
104
+
105
+ """
106
+ self._image_path = path
107
+ self._width = width
108
+ self._height = height
109
+ if self._image_widget is not None:
110
+ self._image_widget.image = path
111
+ self._apply_dimensions()
112
+
113
+ def clear(self) -> None:
114
+ """Clear the image display."""
115
+ self._image_path = None
116
+ if self._image_widget is not None:
117
+ self._image_widget.image = None