Coverage for src/secchi/ui/widgets/modals.py: 38%
125 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Modal screens — fuzzy package search and the help overlay."""
3from __future__ import annotations
5from typing import ClassVar
7from textual import on
8from textual.app import ComposeResult
9from textual.binding import Binding
10from textual.containers import Horizontal, Vertical
11from textual.screen import ModalScreen
12from textual.widgets import Button, Input, OptionList, Static
13from textual.widgets.option_list import Option
15from secchi.models import PackageRef, Project
17_SHORTCUTS = [
18 ("↑ / ↓", "Move the selection in the sidebar"),
19 ("Enter", "Open the selected package"),
20 ("/", "Search packages by name"),
21 ("r", "Refresh the selected project"),
22 ("f", "Toggle favorites-only filter"),
23 ("?", "Show this help"),
24 ("q / Ctrl+C", "Quit secchi"),
25 ("Esc", "Close overlay / dismiss"),
26]
29class SearchScreen(ModalScreen[PackageRef | None]):
30 """Substring search over the project's packages."""
32 BINDINGS: ClassVar[list[Binding]] = [
33 Binding("escape", "dismiss_screen", "Close", show=False)
34 ]
36 def __init__(self, project: Project) -> None:
37 super().__init__()
38 self._project = project
40 def compose(self) -> ComposeResult:
41 with Vertical(id="search-box"):
42 yield Static("Search packages", classes="modal-title")
43 yield Input(placeholder="Type to filter…", id="search-input")
44 yield OptionList(id="search-results")
46 def on_mount(self) -> None:
47 self._populate("")
48 self.query_one("#search-input", Input).focus()
50 def _populate(self, query: str) -> None:
51 results = self.query_one("#search-results", OptionList)
52 results.clear_options()
53 q = query.lower().strip()
54 for ref in self._visible_packages():
55 if q and q not in ref.name.lower():
56 continue
57 star = "★ " if ref.favorite else " "
58 label = f"{star}{ref.name} [dim]{ref.registry.display_name}[/]"
59 results.add_option(Option(label, id=self._key(ref)))
61 def _key(self, ref: PackageRef) -> str:
62 project = f"{ref.project_name}:" if ref.project_name else ""
63 return f"{project}{ref.registry.value}:{ref.name}"
65 @on(Input.Changed, "#search-input")
66 def _on_change(self, event: Input.Changed) -> None:
67 self._populate(event.value)
69 @on(Input.Submitted, "#search-input")
70 def _on_submit(self) -> None:
71 results = self.query_one("#search-results", OptionList)
72 if results.option_count > 0:
73 highlighted = results.highlighted or 0
74 option = results.get_option_at_index(highlighted)
75 self._select(option.id)
77 @on(OptionList.OptionSelected, "#search-results")
78 def _on_option(self, event: OptionList.OptionSelected) -> None:
79 self._select(event.option.id)
81 def _select(self, key: str | None) -> None:
82 if not key:
83 self.dismiss(None)
84 return
85 for ref in self._visible_packages():
86 if self._key(ref) == key:
87 self.dismiss(ref)
88 return
89 self.dismiss(None)
91 def _visible_packages(self) -> list[PackageRef]:
92 seen: dict[str, PackageRef] = {}
93 for ref in self._project.packages:
94 key = f"{ref.project_name}:{ref.name.lower()}"
95 current = seen.get(key)
96 if current is None:
97 seen[key] = PackageRef(
98 ref.name, ref.registry, ref.favorite, ref.project_name
99 )
100 elif ref.favorite and not current.favorite:
101 current.favorite = True
102 return list(seen.values())
104 def action_dismiss_screen(self) -> None:
105 self.dismiss(None)
108class HelpScreen(ModalScreen[None]):
109 """Keyboard shortcut reference overlay."""
111 BINDINGS: ClassVar[list[Binding]] = [
112 Binding("escape,q,question_mark", "dismiss_screen", "Close", show=False)
113 ]
115 def compose(self) -> ComposeResult:
116 rows = "\n".join(f"[b white]{k:<12}[/] [white]{v}[/]" for k, v in _SHORTCUTS)
117 with Vertical(id="help-box"):
118 yield Static("Keyboard Shortcuts", classes="modal-title")
119 yield Static(rows, id="help-body")
120 yield Static("Press Esc to close", classes="modal-hint")
122 def on_key(self) -> None:
123 self.dismiss(None)
125 def action_dismiss_screen(self) -> None:
126 self.dismiss(None)
129class ExportScreen(ModalScreen[str | None]):
130 """Export modal for package or project reports."""
132 BINDINGS: ClassVar[list[Binding]] = [
133 Binding("escape", "dismiss_none", "Cancel", show=False),
134 Binding("left", "focus_left", "Left", show=False),
135 Binding("right", "focus_right", "Right", show=False),
136 ]
138 def __init__(self, project_scope: bool = False) -> None:
139 super().__init__()
140 self._project_scope = project_scope
142 def compose(self) -> ComposeResult:
143 scope = "Project" if self._project_scope else "Package"
144 with Vertical(id="export-box"):
145 yield Static(f"Export {scope} Report", classes="modal-title")
146 yield OptionList(
147 Option(f"{scope} JSON", id="json"),
148 Option(f"{scope} Markdown", id="md"),
149 Option(f"{scope} HTML", id="html"),
150 id="export-options",
151 )
152 with Horizontal(id="export-buttons"):
153 yield Button("OK", variant="primary", id="export-ok")
154 yield Button("Cancel", variant="default", id="export-cancel")
156 def on_mount(self) -> None:
157 options = self.query_one("#export-options", OptionList)
158 options.highlighted = 0
159 options.focus()
161 @on(OptionList.OptionSelected, "#export-options")
162 def _on_option_select(self) -> None:
163 self._do_export()
165 @on(Button.Pressed, "#export-ok")
166 def _on_ok(self) -> None:
167 self._do_export()
169 @on(Button.Pressed, "#export-cancel")
170 def _on_cancel(self) -> None:
171 self.dismiss(None)
173 def _do_export(self) -> None:
174 options = self.query_one("#export-options", OptionList)
175 if options.highlighted is not None:
176 option = options.get_option_at_index(options.highlighted)
177 if option.id in {"json", "md", "html"}:
178 self.dismiss(option.id)
179 return
180 self.query_one("#export-options", OptionList).focus()
181 return
182 self.dismiss(None)
184 def action_focus_left(self) -> None:
185 self.query_one("#export-ok", Button).focus()
187 def action_focus_right(self) -> None:
188 self.query_one("#export-cancel", Button).focus()
190 def action_dismiss_none(self) -> None:
191 self.dismiss(None)