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

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

2 

3from __future__ import annotations 

4 

5from typing import ClassVar 

6 

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 

14 

15from secchi.models import PackageRef, Project 

16 

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] 

27 

28 

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

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

31 

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

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

34 ] 

35 

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

37 super().__init__() 

38 self._project = project 

39 

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

45 

46 def on_mount(self) -> None: 

47 self._populate("") 

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

49 

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

60 

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

64 

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

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

67 self._populate(event.value) 

68 

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) 

76 

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

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

79 self._select(event.option.id) 

80 

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) 

90 

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

103 

104 def action_dismiss_screen(self) -> None: 

105 self.dismiss(None) 

106 

107 

108class HelpScreen(ModalScreen[None]): 

109 """Keyboard shortcut reference overlay.""" 

110 

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

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

113 ] 

114 

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

121 

122 def on_key(self) -> None: 

123 self.dismiss(None) 

124 

125 def action_dismiss_screen(self) -> None: 

126 self.dismiss(None) 

127 

128 

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

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

131 

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 ] 

137 

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

139 super().__init__() 

140 self._project_scope = project_scope 

141 

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

155 

156 def on_mount(self) -> None: 

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

158 options.highlighted = 0 

159 options.focus() 

160 

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

162 def _on_option_select(self) -> None: 

163 self._do_export() 

164 

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

166 def _on_ok(self) -> None: 

167 self._do_export() 

168 

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

170 def _on_cancel(self) -> None: 

171 self.dismiss(None) 

172 

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) 

183 

184 def action_focus_left(self) -> None: 

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

186 

187 def action_focus_right(self) -> None: 

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

189 

190 def action_dismiss_none(self) -> None: 

191 self.dismiss(None)