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/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()
|
prezo/screens/search.py
ADDED
|
@@ -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)
|