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

1"""Modal screens — fuzzy package search and the help overlay.""" 

2 

3from __future__ import annotations 

4 

5from typing import ClassVar 

6 

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 

15 

16from secchi.diagnostics import DiagnosticLog, DiagnosticStatus 

17from secchi.models import PackageRef, Project 

18 

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] 

31 

32 

33class SearchScreen(ModalScreen[PackageRef | None]): 

34 """Substring search over the project's packages.""" 

35 

36 BINDINGS: ClassVar[list[Binding]] = [ 

37 Binding("escape", "dismiss_screen", "Close", show=False) 

38 ] 

39 

40 def __init__(self, project: Project) -> None: 

41 super().__init__() 

42 self._project = project 

43 

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") 

49 

50 def on_mount(self) -> None: 

51 self._populate("") 

52 self.query_one("#search-input", Input).focus() 

53 

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))) 

64 

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}" 

68 

69 @on(Input.Changed, "#search-input") 

70 def _on_change(self, event: Input.Changed) -> None: 

71 self._populate(event.value) 

72 

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) 

80 

81 @on(OptionList.OptionSelected, "#search-results") 

82 def _on_option(self, event: OptionList.OptionSelected) -> None: 

83 self._select(event.option.id) 

84 

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) 

94 

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()) 

107 

108 def action_dismiss_screen(self) -> None: 

109 self.dismiss(None) 

110 

111 

112class HelpScreen(ModalScreen[None]): 

113 """Keyboard shortcut reference overlay.""" 

114 

115 BINDINGS: ClassVar[list[Binding]] = [ 

116 Binding("escape,q,question_mark", "dismiss_screen", "Close", show=False) 

117 ] 

118 

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") 

125 

126 def on_key(self) -> None: 

127 self.dismiss(None) 

128 

129 def action_dismiss_screen(self) -> None: 

130 self.dismiss(None) 

131 

132 

133class LogsScreen(ModalScreen[None]): 

134 """Readable session diagnostics for registry and package processing.""" 

135 

136 BINDINGS: ClassVar[list[Binding]] = [ 

137 Binding("escape,l,q", "dismiss_screen", "Close", show=False), 

138 Binding("c", "copy_logs", "Copy", show=False), 

139 ] 

140 

141 def __init__(self, diagnostics: DiagnosticLog) -> None: 

142 super().__init__() 

143 self._diagnostics = diagnostics 

144 

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") 

150 

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) 

166 

167 def action_dismiss_screen(self) -> None: 

168 self.dismiss(None) 

169 

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") 

177 

178 

179class ExportScreen(ModalScreen[str | None]): 

180 """Export modal for package or project reports.""" 

181 

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 ] 

187 

188 def __init__(self, project_scope: bool = False) -> None: 

189 super().__init__() 

190 self._project_scope = project_scope 

191 

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") 

205 

206 def on_mount(self) -> None: 

207 options = self.query_one("#export-options", OptionList) 

208 options.highlighted = 0 

209 options.focus() 

210 

211 @on(OptionList.OptionSelected, "#export-options") 

212 def _on_option_select(self) -> None: 

213 self._do_export() 

214 

215 @on(Button.Pressed, "#export-ok") 

216 def _on_ok(self) -> None: 

217 self._do_export() 

218 

219 @on(Button.Pressed, "#export-cancel") 

220 def _on_cancel(self) -> None: 

221 self.dismiss(None) 

222 

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) 

233 

234 def action_focus_left(self) -> None: 

235 self.query_one("#export-ok", Button).focus() 

236 

237 def action_focus_right(self) -> None: 

238 self.query_one("#export-cancel", Button).focus() 

239 

240 def action_dismiss_none(self) -> None: 

241 self.dismiss(None)