Coverage for src/secchi/ui/widgets/modals.py: 36%
157 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Modal screens — fuzzy package search and the help overlay."""
3from __future__ import annotations
5from typing import ClassVar
7from rich.text import Text
8from textual import on
9from textual.app import ComposeResult
10from textual.binding import Binding
11from textual.containers import Horizontal, Vertical
12from textual.screen import ModalScreen
13from textual.widgets import Button, Input, OptionList, RichLog, Static
14from textual.widgets.option_list import Option
16from secchi.diagnostics import DiagnosticLog, DiagnosticStatus
17from secchi.models import PackageRef, Project
19_SHORTCUTS = [
20 ("↑ / ↓", "Move the selection in the sidebar"),
21 ("Enter", "Open the selected package"),
22 ("/", "Search packages by name"),
23 ("r", "Refresh the selected project"),
24 ("f", "Toggle favorites-only filter"),
25 ("l", "Show process logs"),
26 ("c", "Copy process logs"),
27 ("?", "Show this help"),
28 ("q / Ctrl+C", "Quit secchi"),
29 ("Esc", "Close overlay / dismiss"),
30]
33class SearchScreen(ModalScreen[PackageRef | None]):
34 """Substring search over the project's packages."""
36 BINDINGS: ClassVar[list[Binding]] = [
37 Binding("escape", "dismiss_screen", "Close", show=False)
38 ]
40 def __init__(self, project: Project) -> None:
41 super().__init__()
42 self._project = project
44 def compose(self) -> ComposeResult:
45 with Vertical(id="search-box"):
46 yield Static("Search packages", classes="modal-title")
47 yield Input(placeholder="Type to filter…", id="search-input")
48 yield OptionList(id="search-results")
50 def on_mount(self) -> None:
51 self._populate("")
52 self.query_one("#search-input", Input).focus()
54 def _populate(self, query: str) -> None:
55 results = self.query_one("#search-results", OptionList)
56 results.clear_options()
57 q = query.lower().strip()
58 for ref in self._visible_packages():
59 if q and q not in ref.name.lower():
60 continue
61 star = "★ " if ref.favorite else " "
62 label = f"{star}{ref.name} [dim]{ref.registry.display_name}[/]"
63 results.add_option(Option(label, id=self._key(ref)))
65 def _key(self, ref: PackageRef) -> str:
66 project = f"{ref.project_name}:" if ref.project_name else ""
67 return f"{project}{ref.registry.value}:{ref.name}"
69 @on(Input.Changed, "#search-input")
70 def _on_change(self, event: Input.Changed) -> None:
71 self._populate(event.value)
73 @on(Input.Submitted, "#search-input")
74 def _on_submit(self) -> None:
75 results = self.query_one("#search-results", OptionList)
76 if results.option_count > 0:
77 highlighted = results.highlighted or 0
78 option = results.get_option_at_index(highlighted)
79 self._select(option.id)
81 @on(OptionList.OptionSelected, "#search-results")
82 def _on_option(self, event: OptionList.OptionSelected) -> None:
83 self._select(event.option.id)
85 def _select(self, key: str | None) -> None:
86 if not key:
87 self.dismiss(None)
88 return
89 for ref in self._visible_packages():
90 if self._key(ref) == key:
91 self.dismiss(ref)
92 return
93 self.dismiss(None)
95 def _visible_packages(self) -> list[PackageRef]:
96 seen: dict[str, PackageRef] = {}
97 for ref in self._project.packages:
98 key = f"{ref.project_name}:{ref.name.lower()}"
99 current = seen.get(key)
100 if current is None:
101 seen[key] = PackageRef(
102 ref.name, ref.registry, ref.favorite, ref.project_name
103 )
104 elif ref.favorite and not current.favorite:
105 current.favorite = True
106 return list(seen.values())
108 def action_dismiss_screen(self) -> None:
109 self.dismiss(None)
112class HelpScreen(ModalScreen[None]):
113 """Keyboard shortcut reference overlay."""
115 BINDINGS: ClassVar[list[Binding]] = [
116 Binding("escape,q,question_mark", "dismiss_screen", "Close", show=False)
117 ]
119 def compose(self) -> ComposeResult:
120 rows = "\n".join(f"[b white]{k:<12}[/] [white]{v}[/]" for k, v in _SHORTCUTS)
121 with Vertical(id="help-box"):
122 yield Static("Keyboard Shortcuts", classes="modal-title")
123 yield Static(rows, id="help-body")
124 yield Static("Press Esc to close", classes="modal-hint")
126 def on_key(self) -> None:
127 self.dismiss(None)
129 def action_dismiss_screen(self) -> None:
130 self.dismiss(None)
133class LogsScreen(ModalScreen[None]):
134 """Readable session diagnostics for registry and package processing."""
136 BINDINGS: ClassVar[list[Binding]] = [
137 Binding("escape,l,q", "dismiss_screen", "Close", show=False),
138 Binding("c", "copy_logs", "Copy", show=False),
139 ]
141 def __init__(self, diagnostics: DiagnosticLog) -> None:
142 super().__init__()
143 self._diagnostics = diagnostics
145 def compose(self) -> ComposeResult:
146 with Vertical(id="logs-box"):
147 yield Static("Process Logs", classes="modal-title")
148 yield RichLog(id="diagnostic-log", highlight=False, markup=False)
149 yield Static("Press c to copy · Esc or l to close", classes="modal-hint")
151 def on_mount(self) -> None:
152 log = self.query_one("#diagnostic-log", RichLog)
153 events = self._diagnostics.snapshot()
154 if not events:
155 log.write("No diagnostic events recorded yet.")
156 return
157 for event in events:
158 style = {
159 DiagnosticStatus.SUCCESS: "green",
160 DiagnosticStatus.WARN: "yellow",
161 DiagnosticStatus.FAILURE: "red",
162 }[event.status]
163 text = Text(event.format())
164 text.stylize(style, 9, 9 + len(event.status.value))
165 log.write(text)
167 def action_dismiss_screen(self) -> None:
168 self.dismiss(None)
170 def action_copy_logs(self) -> None:
171 events = self._diagnostics.snapshot()
172 if not events:
173 self.app.notify("No diagnostic events to copy.", severity="warning")
174 return
175 self.app.copy_to_clipboard("\n".join(event.format() for event in events))
176 self.app.notify("Copied process logs to clipboard.", title="Logs")
179class ExportScreen(ModalScreen[str | None]):
180 """Export modal for package or project reports."""
182 BINDINGS: ClassVar[list[Binding]] = [
183 Binding("escape", "dismiss_none", "Cancel", show=False),
184 Binding("left", "focus_left", "Left", show=False),
185 Binding("right", "focus_right", "Right", show=False),
186 ]
188 def __init__(self, project_scope: bool = False) -> None:
189 super().__init__()
190 self._project_scope = project_scope
192 def compose(self) -> ComposeResult:
193 scope = "Project" if self._project_scope else "Package"
194 with Vertical(id="export-box"):
195 yield Static(f"Export {scope} Report", classes="modal-title")
196 yield OptionList(
197 Option(f"{scope} JSON", id="json"),
198 Option(f"{scope} Markdown", id="md"),
199 Option(f"{scope} HTML", id="html"),
200 id="export-options",
201 )
202 with Horizontal(id="export-buttons"):
203 yield Button("OK", variant="primary", id="export-ok")
204 yield Button("Cancel", variant="default", id="export-cancel")
206 def on_mount(self) -> None:
207 options = self.query_one("#export-options", OptionList)
208 options.highlighted = 0
209 options.focus()
211 @on(OptionList.OptionSelected, "#export-options")
212 def _on_option_select(self) -> None:
213 self._do_export()
215 @on(Button.Pressed, "#export-ok")
216 def _on_ok(self) -> None:
217 self._do_export()
219 @on(Button.Pressed, "#export-cancel")
220 def _on_cancel(self) -> None:
221 self.dismiss(None)
223 def _do_export(self) -> None:
224 options = self.query_one("#export-options", OptionList)
225 if options.highlighted is not None:
226 option = options.get_option_at_index(options.highlighted)
227 if option.id in {"json", "md", "html"}:
228 self.dismiss(option.id)
229 return
230 self.query_one("#export-options", OptionList).focus()
231 return
232 self.dismiss(None)
234 def action_focus_left(self) -> None:
235 self.query_one("#export-ok", Button).focus()
237 def action_focus_right(self) -> None:
238 self.query_one("#export-cancel", Button).focus()
240 def action_dismiss_none(self) -> None:
241 self.dismiss(None)