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/__init__.py +216 -0
- prezo/app.py +947 -0
- prezo/config.py +247 -0
- prezo/export.py +833 -0
- prezo/images/__init__.py +14 -0
- prezo/images/ascii.py +240 -0
- prezo/images/base.py +111 -0
- prezo/images/chafa.py +137 -0
- prezo/images/iterm.py +126 -0
- prezo/images/kitty.py +360 -0
- prezo/images/overlay.py +291 -0
- prezo/images/processor.py +139 -0
- prezo/images/sixel.py +180 -0
- prezo/parser.py +456 -0
- prezo/screens/__init__.py +21 -0
- prezo/screens/base.py +65 -0
- prezo/screens/blackout.py +60 -0
- prezo/screens/goto.py +99 -0
- prezo/screens/help.py +140 -0
- prezo/screens/overview.py +184 -0
- prezo/screens/search.py +252 -0
- prezo/screens/toc.py +254 -0
- prezo/terminal.py +147 -0
- prezo/themes.py +129 -0
- prezo/widgets/__init__.py +9 -0
- prezo/widgets/image_display.py +117 -0
- prezo/widgets/slide_button.py +72 -0
- prezo/widgets/status_bar.py +240 -0
- prezo-0.3.1.dist-info/METADATA +194 -0
- prezo-0.3.1.dist-info/RECORD +32 -0
- prezo-0.3.1.dist-info/WHEEL +4 -0
- prezo-0.3.1.dist-info/entry_points.txt +3 -0
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
|