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/help.py ADDED
@@ -0,0 +1,140 @@
1
+ """Help screen for Prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from textual.binding import Binding, BindingType
8
+ from textual.containers import VerticalScroll
9
+ from textual.widgets import Markdown, Static
10
+
11
+ from .base import ThemedModalScreen
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.app import ComposeResult
15
+
16
+ HELP_CONTENT = """\
17
+ # Prezo Help
18
+
19
+ ## Navigation Keys
20
+
21
+ | Key | Action |
22
+ |-----|--------|
23
+ | **→** / **j** / **Space** | Next slide |
24
+ | **←** / **k** | Previous slide |
25
+ | **Home** / **g** | First slide |
26
+ | **End** / **G** | Last slide |
27
+ | **:** | Go to specific slide |
28
+ | **/** | Search slides |
29
+ | **o** | Slide overview grid |
30
+ | **t** | Table of contents |
31
+
32
+ ## Display Options
33
+
34
+ | Key | Action |
35
+ |-----|--------|
36
+ | **p** | Toggle presenter notes |
37
+ | **c** | Cycle clock/timer modes |
38
+ | **T** | Cycle through themes |
39
+ | **b** | Blackout screen |
40
+ | **w** | Whiteout screen |
41
+
42
+ ## Editing & Files
43
+
44
+ | Key | Action |
45
+ |-----|--------|
46
+ | **e** | Edit current slide in $EDITOR |
47
+ | **r** | Reload presentation |
48
+
49
+ ## Other
50
+
51
+ | Key | Action |
52
+ |-----|--------|
53
+ | **Ctrl+P** | Command palette |
54
+ | **?** | Show this help |
55
+ | **q** | Quit |
56
+ | **Escape** | Close dialogs |
57
+
58
+ ## Presentation Format
59
+
60
+ Prezo supports **MARP/Deckset** style Markdown:
61
+
62
+ - YAML frontmatter for metadata
63
+ - `---` to separate slides
64
+ - `???` or `<!-- notes: -->` for presenter notes
65
+
66
+ ### Prezo Directives
67
+
68
+ Add configuration to your presentation:
69
+
70
+ ```markdown
71
+ <!-- prezo
72
+ theme: dark
73
+ show_clock: true
74
+ countdown_minutes: 45
75
+ -->
76
+ ```
77
+
78
+ ### Supported Directives
79
+
80
+ - `theme`: dark, light, dracula, solarized-dark, nord, gruvbox
81
+ - `show_clock`: true/false
82
+ - `show_elapsed`: true/false
83
+ - `countdown_minutes`: number
84
+
85
+ ## Documentation
86
+
87
+ - **GitHub**: https://github.com/abilian/prezo
88
+ - **SourceHut**: https://git.sr.ht/~sfermigier/prezo
89
+ - **Issues**: https://github.com/abilian/prezo/issues
90
+
91
+ ---
92
+
93
+ *Press Escape or ? to close*
94
+ """
95
+
96
+
97
+ class HelpScreen(ThemedModalScreen[None]):
98
+ """Modal screen showing help content."""
99
+
100
+ CSS = """
101
+ HelpScreen {
102
+ align: center middle;
103
+ }
104
+
105
+ #help-container {
106
+ width: 80%;
107
+ max-width: 100;
108
+ height: 80%;
109
+ background: $surface;
110
+ border: solid $primary;
111
+ padding: 1 2;
112
+ }
113
+
114
+ #help-title {
115
+ text-align: center;
116
+ text-style: bold;
117
+ color: $primary;
118
+ padding: 0 0 1 0;
119
+ }
120
+
121
+ #help-content {
122
+ width: 100%;
123
+ }
124
+ """
125
+
126
+ BINDINGS: ClassVar[list[BindingType]] = [
127
+ Binding("escape", "close", "Close"),
128
+ Binding("question_mark", "close", "Close"),
129
+ Binding("q", "close", "Close"),
130
+ ]
131
+
132
+ def compose(self) -> ComposeResult:
133
+ """Compose the help screen layout."""
134
+ with VerticalScroll(id="help-container"):
135
+ yield Static("Prezo Help", id="help-title")
136
+ yield Markdown(HELP_CONTENT, id="help-content")
137
+
138
+ def action_close(self) -> None:
139
+ """Close the help screen."""
140
+ self.dismiss(None)
@@ -0,0 +1,184 @@
1
+ """Slide overview screen for prezo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from textual.binding import Binding, BindingType
8
+ from textual.containers import Grid, VerticalScroll
9
+ from textual.widgets import Button, Static
10
+
11
+ from prezo.widgets import SlideButton
12
+
13
+ from .base import ThemedModalScreen
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.app import ComposeResult
17
+
18
+ from prezo.parser import Presentation
19
+
20
+
21
+ class SlideOverviewScreen(ThemedModalScreen[int | None]):
22
+ """Modal screen showing grid overview of all slides."""
23
+
24
+ GRID_COLUMNS = 4 # Number of columns in the grid
25
+
26
+ CSS = """
27
+ SlideOverviewScreen {
28
+ align: center middle;
29
+ }
30
+
31
+ #overview-container {
32
+ width: 90%;
33
+ height: 90%;
34
+ background: $surface;
35
+ border: thick $primary;
36
+ padding: 1 2;
37
+ }
38
+
39
+ #overview-title {
40
+ width: 100%;
41
+ height: 3;
42
+ content-align: center middle;
43
+ text-style: bold;
44
+ background: $primary;
45
+ color: $text;
46
+ }
47
+
48
+ #slide-grid {
49
+ width: 100%;
50
+ height: 1fr;
51
+ grid-size: 4;
52
+ grid-gutter: 1;
53
+ padding: 1;
54
+ overflow-y: auto;
55
+ }
56
+
57
+ SlideButton {
58
+ width: 100%;
59
+ height: 3;
60
+ }
61
+
62
+ SlideButton.current {
63
+ background: $success;
64
+ }
65
+
66
+ SlideButton:focus {
67
+ background: $primary;
68
+ }
69
+
70
+ SlideButton.current:focus {
71
+ background: $success-darken-1;
72
+ }
73
+ """
74
+
75
+ BINDINGS: ClassVar[list[BindingType]] = [
76
+ Binding("escape", "cancel", "Cancel"),
77
+ Binding("q", "cancel", "Cancel"),
78
+ Binding("enter", "select", "Select", show=True),
79
+ Binding("space", "select", "Select", show=False),
80
+ Binding("left", "move(-1, 0)", "Left", show=False),
81
+ Binding("right", "move(1, 0)", "Right", show=False),
82
+ Binding("up", "move(0, -1)", "Up", show=False),
83
+ Binding("down", "move(0, 1)", "Down", show=False),
84
+ Binding("h", "move(-1, 0)", "Left", show=False),
85
+ Binding("l", "move(1, 0)", "Right", show=False),
86
+ Binding("k", "move(0, -1)", "Up", show=False),
87
+ Binding("j", "move(0, 1)", "Down", show=False),
88
+ Binding("home", "first", "First", show=False),
89
+ Binding("end", "last", "Last", show=False),
90
+ ]
91
+
92
+ def __init__(self, presentation: Presentation, current_slide: int) -> None:
93
+ """Initialize the slide overview screen.
94
+
95
+ Args:
96
+ presentation: The presentation to display.
97
+ current_slide: Index of the currently active slide.
98
+
99
+ """
100
+ super().__init__()
101
+ self.presentation = presentation
102
+ self.current_slide = current_slide
103
+ self.selected_index = current_slide
104
+
105
+ def compose(self) -> ComposeResult:
106
+ """Compose the overview grid layout."""
107
+ with VerticalScroll(id="overview-container"):
108
+ yield Static(
109
+ " Slide Overview (Enter to jump, Esc to cancel) ",
110
+ id="overview-title",
111
+ )
112
+ with Grid(id="slide-grid"):
113
+ for i, slide in enumerate(self.presentation.slides):
114
+ yield SlideButton(
115
+ i,
116
+ slide.content,
117
+ is_current=(i == self.current_slide),
118
+ )
119
+
120
+ def on_mount(self) -> None:
121
+ """Focus the current slide button on mount."""
122
+ super().on_mount()
123
+ self._focus_selected()
124
+
125
+ def _focus_selected(self) -> None:
126
+ """Focus the currently selected slide button."""
127
+ try:
128
+ button = self.query_one(f"#slide-{self.selected_index}", SlideButton)
129
+ button.focus()
130
+ button.scroll_visible()
131
+ except (KeyError, LookupError):
132
+ # Button not found - can happen during initialization
133
+ pass
134
+
135
+ def on_button_pressed(self, event: Button.Pressed) -> None:
136
+ """Handle slide button press to navigate to that slide."""
137
+ if isinstance(event.button, SlideButton):
138
+ self.dismiss(event.button.slide_index)
139
+
140
+ def action_cancel(self) -> None:
141
+ """Cancel and dismiss the overview."""
142
+ self.dismiss(None)
143
+
144
+ def action_select(self) -> None:
145
+ """Select the currently focused slide."""
146
+ self.dismiss(self.selected_index)
147
+
148
+ def action_move(self, dx: int, dy: int) -> None:
149
+ """Move selection in the grid."""
150
+ total = self.presentation.total_slides
151
+ cols = self.GRID_COLUMNS
152
+
153
+ # Calculate new position
154
+ row = self.selected_index // cols
155
+ col = self.selected_index % cols
156
+
157
+ new_col = col + dx
158
+ new_row = row + dy
159
+
160
+ # Handle horizontal wrapping
161
+ if new_col < 0:
162
+ new_col = cols - 1
163
+ new_row -= 1
164
+ elif new_col >= cols:
165
+ new_col = 0
166
+ new_row += 1
167
+
168
+ # Calculate new index
169
+ new_index = new_row * cols + new_col
170
+
171
+ # Clamp to valid range
172
+ if 0 <= new_index < total:
173
+ self.selected_index = new_index
174
+ self._focus_selected()
175
+
176
+ def action_first(self) -> None:
177
+ """Jump to first slide."""
178
+ self.selected_index = 0
179
+ self._focus_selected()
180
+
181
+ def action_last(self) -> None:
182
+ """Jump to last slide."""
183
+ self.selected_index = self.presentation.total_slides - 1
184
+ self._focus_selected()
@@ -0,0 +1,252 @@
1
+ """Slide search 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 Vertical, VerticalScroll
10
+ from textual.widgets import Input, 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 SearchResultItem(Static):
21
+ """A single search result item."""
22
+
23
+ def __init__(self, slide_index: int, title: str, context: str, **kwargs) -> None:
24
+ """Initialize a search result item.
25
+
26
+ Args:
27
+ slide_index: Index of the slide this result refers to.
28
+ title: Title of the slide.
29
+ context: Context text showing the search match.
30
+ **kwargs: Additional arguments for Static widget.
31
+
32
+ """
33
+ super().__init__(**kwargs)
34
+ self.slide_index = slide_index
35
+ self.title = title
36
+ self.context = context
37
+
38
+ def render(self) -> str:
39
+ """Render the search result item."""
40
+ return f"[{self.slide_index + 1}] {self.title}\n {self.context}"
41
+
42
+
43
+ class SlideSearchScreen(ThemedModalScreen[int | None]):
44
+ """Modal screen for searching slides by content."""
45
+
46
+ CSS = """
47
+ SlideSearchScreen {
48
+ align: center middle;
49
+ }
50
+
51
+ #search-container {
52
+ width: 80%;
53
+ height: 80%;
54
+ background: $surface;
55
+ border: thick $primary;
56
+ padding: 1 2;
57
+ }
58
+
59
+ #search-title {
60
+ width: 100%;
61
+ text-align: center;
62
+ text-style: bold;
63
+ margin-bottom: 1;
64
+ }
65
+
66
+ #search-input {
67
+ width: 100%;
68
+ margin-bottom: 1;
69
+ }
70
+
71
+ #search-results {
72
+ width: 100%;
73
+ height: 1fr;
74
+ border: solid $primary-darken-2;
75
+ padding: 1;
76
+ }
77
+
78
+ .search-result {
79
+ width: 100%;
80
+ height: auto;
81
+ padding: 0 1;
82
+ margin-bottom: 1;
83
+ }
84
+
85
+ .search-result:hover {
86
+ background: $primary-darken-2;
87
+ }
88
+
89
+ .search-result.selected {
90
+ background: $primary;
91
+ }
92
+
93
+ #no-results {
94
+ width: 100%;
95
+ text-align: center;
96
+ color: $text-muted;
97
+ padding: 2;
98
+ }
99
+ """
100
+
101
+ BINDINGS: ClassVar[list[BindingType]] = [
102
+ Binding("escape", "cancel", "Cancel"),
103
+ Binding("enter", "select", "Select"),
104
+ Binding("up", "move_up", "Up", show=False),
105
+ Binding("down", "move_down", "Down", show=False),
106
+ Binding("ctrl+p", "move_up", "Up", show=False),
107
+ Binding("ctrl+n", "move_down", "Down", show=False),
108
+ ]
109
+
110
+ def __init__(self, presentation: Presentation) -> None:
111
+ """Initialize the search screen.
112
+
113
+ Args:
114
+ presentation: The presentation to search through.
115
+
116
+ """
117
+ super().__init__()
118
+ self.presentation = presentation
119
+ self.results: list[int] = [] # Slide indices
120
+ self.selected_index = 0
121
+
122
+ def compose(self) -> ComposeResult:
123
+ """Compose the search screen layout."""
124
+ with Vertical(id="search-container"):
125
+ yield Static("Search Slides", id="search-title")
126
+ yield Input(placeholder="Type to search...", id="search-input")
127
+ with VerticalScroll(id="search-results"):
128
+ yield Static("Type to search slide content", id="no-results")
129
+
130
+ def on_mount(self) -> None:
131
+ """Focus the search input on mount."""
132
+ super().on_mount()
133
+ self.query_one("#search-input", Input).focus()
134
+
135
+ def on_input_changed(self, event: Input.Changed) -> None:
136
+ """Handle input changes to perform live search."""
137
+ self._perform_search(event.value)
138
+
139
+ def on_input_submitted(self, event: Input.Submitted) -> None:
140
+ """Handle Enter key in the search input."""
141
+ self.action_select()
142
+
143
+ def _perform_search(self, query: str) -> None:
144
+ """Search slides for the query string."""
145
+ results_container = self.query_one("#search-results", VerticalScroll)
146
+
147
+ # Clear previous results
148
+ for child in list(results_container.children):
149
+ child.remove()
150
+
151
+ if not query.strip():
152
+ results_container.mount(
153
+ Static("Type to search slide content", id="no-results"),
154
+ )
155
+ self.results = []
156
+ return
157
+
158
+ query_lower = query.lower()
159
+ self.results = []
160
+
161
+ for i, slide in enumerate(self.presentation.slides):
162
+ if (
163
+ query_lower in slide.content.lower()
164
+ or query_lower in slide.raw_content.lower()
165
+ ):
166
+ self.results.append(i)
167
+
168
+ if not self.results:
169
+ results_container.mount(
170
+ Static(f"No results for '{query}'", id="no-results"),
171
+ )
172
+ return
173
+
174
+ self.selected_index = 0
175
+ for idx, slide_idx in enumerate(self.results):
176
+ slide = self.presentation.slides[slide_idx]
177
+ title = self._extract_title(slide.content)
178
+ context = self._extract_context(slide.content, query_lower)
179
+
180
+ item = SearchResultItem(
181
+ slide_idx,
182
+ title,
183
+ context,
184
+ classes="search-result" + (" selected" if idx == 0 else ""),
185
+ )
186
+ results_container.mount(item)
187
+
188
+ def _extract_title(self, content: str) -> str:
189
+ """Extract title from slide content."""
190
+ for line in content.strip().split("\n"):
191
+ line = line.strip()
192
+ match = re.match(r"^#{1,6}\s+(.+)$", line)
193
+ if match:
194
+ return match.group(1).strip()[:50]
195
+ return content.strip().split("\n")[0][:50] if content.strip() else "Untitled"
196
+
197
+ def _extract_context(self, content: str, query: str) -> str:
198
+ """Extract context around the search match."""
199
+ content_lower = content.lower()
200
+ pos = content_lower.find(query)
201
+ if pos == -1:
202
+ return ""
203
+
204
+ start = max(0, pos - 20)
205
+ end = min(len(content), pos + len(query) + 30)
206
+
207
+ context = content[start:end].replace("\n", " ")
208
+ if start > 0:
209
+ context = "..." + context
210
+ if end < len(content):
211
+ context = context + "..."
212
+
213
+ return context
214
+
215
+ def _update_selection(self) -> None:
216
+ """Update visual selection."""
217
+ results_container = self.query_one("#search-results", VerticalScroll)
218
+ for idx, child in enumerate(results_container.query(".search-result")):
219
+ if idx == self.selected_index:
220
+ child.add_class("selected")
221
+ child.scroll_visible()
222
+ else:
223
+ child.remove_class("selected")
224
+
225
+ def action_cancel(self) -> None:
226
+ """Cancel and dismiss the search screen."""
227
+ self.dismiss(None)
228
+
229
+ def action_select(self) -> None:
230
+ """Select the currently highlighted search result."""
231
+ if self.results and 0 <= self.selected_index < len(self.results):
232
+ self.dismiss(self.results[self.selected_index])
233
+ else:
234
+ self.dismiss(None)
235
+
236
+ def action_move_up(self) -> None:
237
+ """Move selection up in the results list."""
238
+ if self.results and self.selected_index > 0:
239
+ self.selected_index -= 1
240
+ self._update_selection()
241
+
242
+ def action_move_down(self) -> None:
243
+ """Move selection down in the results list."""
244
+ if self.results and self.selected_index < len(self.results) - 1:
245
+ self.selected_index += 1
246
+ self._update_selection()
247
+
248
+ def on_click(self, event) -> None:
249
+ """Handle clicking on a search result."""
250
+ widget = self.get_widget_at(event.screen_x, event.screen_y)
251
+ if widget and isinstance(widget, SearchResultItem):
252
+ self.dismiss(widget.slide_index)