Coverage for openhcs/textual_tui/windows/help_windows.py: 0.0%

179 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1"""Unified help system for displaying docstring and parameter information.""" 

2 

3from typing import Union, Callable, Optional 

4from textual import on 

5from textual.app import ComposeResult 

6from textual.containers import ScrollableContainer, Horizontal 

7from textual.widgets import Button, Static, Markdown 

8from textual.css.query import NoMatches 

9 

10from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

11from openhcs.textual_tui.widgets.shared.signature_analyzer import DocstringExtractor 

12 

13 

14class BaseHelpWindow(BaseOpenHCSWindow): 

15 """Base class for all help windows with unified button handling.""" 

16 

17 @on(Button.Pressed, "#close") 

18 def close_help(self) -> None: 

19 """Handle close button press.""" 

20 self.close_window() 

21 

22 

23class DocstringHelpWindow(BaseHelpWindow): 

24 """Window for displaying docstring information.""" 

25 

26 DEFAULT_CSS = """ 

27 DocstringHelpWindow ScrollableContainer { 

28 text-align: left; 

29 align: left top; 

30 } 

31 

32 DocstringHelpWindow Static { 

33 text-align: left; 

34 } 

35 

36 .help-summary { 

37 margin: 1 0; 

38 padding: 1; 

39 background: $surface; 

40 border: solid $primary; 

41 text-align: left; 

42 } 

43 

44 .help-description { 

45 margin: 1 0; 

46 padding: 1; 

47 text-align: left; 

48 } 

49 

50 .help-section-header { 

51 margin: 1 0 0 0; 

52 text-style: bold; 

53 color: $accent; 

54 text-align: left; 

55 } 

56 

57 .help-parameter { 

58 margin: 0 0 0 2; 

59 color: $text; 

60 text-align: left; 

61 } 

62 

63 .help-returns { 

64 margin: 0 0 0 2; 

65 color: $text; 

66 text-align: left; 

67 } 

68 

69 .help-examples { 

70 margin: 1 0; 

71 padding: 1; 

72 background: $surface; 

73 border: solid $accent; 

74 text-align: left; 

75 } 

76 """ 

77 

78 def __init__(self, target: Union[Callable, type], title: Optional[str] = None, **kwargs): 

79 self.target = target 

80 self.docstring_info = DocstringExtractor.extract(target) 

81 

82 # Generate title from target if not provided 

83 if title is None: 

84 if hasattr(target, '__name__'): 

85 title = f"Help: {target.__name__}" 

86 else: 

87 title = "Help" 

88 

89 super().__init__( 

90 window_id="docstring_help", 

91 title=title, 

92 mode="temporary", 

93 **kwargs 

94 ) 

95 

96 # Calculate dynamic minimum size based on content 

97 self._calculate_dynamic_size() 

98 

99 def compose(self) -> ComposeResult: 

100 """Compose the help window content with scrollable area.""" 

101 # Scrollable content area 

102 with ScrollableContainer(): 

103 # Function/class summary 

104 if self.docstring_info.summary: 

105 yield Static(f"[bold]{self.docstring_info.summary}[/bold]", classes="help-summary") 

106 

107 # Full description 

108 if self.docstring_info.description: 

109 yield Markdown(self.docstring_info.description, classes="help-description") 

110 

111 # Parameters section 

112 if self.docstring_info.parameters: 

113 yield Static("[bold]Parameters[/bold]", classes="help-section-header") 

114 for param_name, param_desc in self.docstring_info.parameters.items(): 

115 yield Static(f"• [bold]{param_name}[/bold]: {param_desc}", classes="help-parameter") 

116 # Add spacing between parameters for better readability 

117 yield Static("", classes="help-parameter-spacer") 

118 

119 # Returns section 

120 if self.docstring_info.returns: 

121 yield Static("[bold]Returns:[/bold]", classes="help-section-header") 

122 yield Static(f"{self.docstring_info.returns}", classes="help-returns") 

123 

124 # Examples section 

125 if self.docstring_info.examples: 

126 yield Static("[bold]Examples:[/bold]", classes="help-section-header") 

127 yield Markdown(f"```python\n{self.docstring_info.examples}\n```", classes="help-examples") 

128 

129 # Close button at bottom - wrapped in Horizontal for automatic centering 

130 with Horizontal(): 

131 yield Button("Close", id="close", compact=True) 

132 

133 def _calculate_dynamic_size(self) -> None: 

134 """Calculate and set dynamic minimum window size based on content.""" 

135 try: 

136 # Calculate width based on longest line in content 

137 max_width = 40 # Base minimum width 

138 

139 # Check summary length 

140 if self.docstring_info.summary: 

141 max_width = max(max_width, len(self.docstring_info.summary) + 10) 

142 

143 # Check parameter descriptions 

144 if self.docstring_info.parameters: 

145 for param_name, param_desc in self.docstring_info.parameters.items(): 

146 line_length = len(f"{param_name}: {param_desc}") 

147 max_width = max(max_width, line_length + 10) 

148 

149 # Check returns description 

150 if self.docstring_info.returns: 

151 max_width = max(max_width, len(self.docstring_info.returns) + 10) 

152 

153 # Calculate height based on content sections 

154 min_height = 10 # Base minimum height 

155 content_lines = 0 

156 

157 if self.docstring_info.summary: 

158 content_lines += 2 # Summary + margin 

159 if self.docstring_info.description: 

160 # Estimate lines for description (rough approximation) 

161 content_lines += max(3, len(self.docstring_info.description) // 60) 

162 if self.docstring_info.parameters: 

163 content_lines += 1 + len(self.docstring_info.parameters) # Header + params 

164 if self.docstring_info.returns: 

165 content_lines += 2 # Header + returns 

166 if self.docstring_info.examples: 

167 content_lines += 5 # Header + example block 

168 

169 content_lines += 3 # Close button + margins 

170 min_height = max(min_height, content_lines) 

171 

172 # Cap maximum size to reasonable limits 

173 max_width = min(max_width, 120) 

174 min_height = min(min_height, 40) 

175 

176 # Set dynamic window size 

177 self.styles.width = max_width 

178 self.styles.height = min_height 

179 

180 except Exception: 

181 # Fallback to default sizes if calculation fails 

182 self.styles.width = 60 

183 self.styles.height = 20 

184 

185 

186class ParameterHelpWindow(BaseHelpWindow): 

187 """Window for displaying individual parameter documentation.""" 

188 

189 DEFAULT_CSS = """ 

190 ParameterHelpWindow ScrollableContainer { 

191 text-align: left; 

192 align: left top; 

193 } 

194 

195 ParameterHelpWindow Static { 

196 text-align: left; 

197 } 

198 

199 .param-header { 

200 margin: 0 0 1 0; 

201 text-style: bold; 

202 color: $primary; 

203 text-align: left; 

204 } 

205 

206 .param-description { 

207 margin: 1 0; 

208 text-align: left; 

209 } 

210 

211 .param-no-desc { 

212 margin: 1 0; 

213 color: $text-muted; 

214 text-style: italic; 

215 text-align: left; 

216 } 

217 """ 

218 

219 def __init__(self, param_name: str, param_description: str, param_type: type = None, **kwargs): 

220 self.param_name = param_name 

221 self.param_description = param_description 

222 self.param_type = param_type 

223 

224 title = f"Help: {param_name}" 

225 

226 super().__init__( 

227 window_id="parameter_help", 

228 title=title, 

229 mode="temporary", 

230 **kwargs 

231 ) 

232 

233 # Calculate dynamic minimum size based on content 

234 self._calculate_dynamic_size() 

235 

236 def compose(self) -> ComposeResult: 

237 """Compose the parameter help content with scrollable area.""" 

238 # Scrollable content area 

239 with ScrollableContainer(): 

240 # Parameter name and type 

241 type_info = f" ({self._format_type_info(self.param_type)})" if self.param_type else "" 

242 yield Static(f"[bold]{self.param_name}[/bold]{type_info}", classes="param-header") 

243 

244 # Parameter description 

245 if self.param_description: 

246 yield Markdown(self.param_description.rstrip(), classes="param-description") 

247 else: 

248 yield Static("[dim]No description available[/dim]", classes="param-no-desc") 

249 

250 # Close button at bottom - wrapped in Horizontal for automatic centering 

251 with Horizontal(): 

252 yield Button("Close", id="close", compact=True) 

253 

254 def _calculate_dynamic_size(self) -> None: 

255 """Calculate and set dynamic minimum window size based on parameter content.""" 

256 try: 

257 # Calculate width based on parameter content 

258 max_width = 30 # Base minimum width for parameter windows 

259 

260 # Check parameter name length 

261 max_width = max(max_width, len(self.param_name) + 15) 

262 

263 # Check parameter description length 

264 if self.param_description: 

265 # Split description into lines and find longest 

266 desc_lines = self.param_description.split('\n') 

267 for line in desc_lines: 

268 max_width = max(max_width, len(line) + 10) 

269 

270 # Check parameter type length 

271 if self.param_type: 

272 type_str = str(self.param_type) 

273 max_width = max(max_width, len(f"Type: {type_str}") + 10) 

274 

275 # Calculate height based on content 

276 min_height = 8 # Base minimum height for parameter windows 

277 content_lines = 2 # Parameter name + margin 

278 

279 if self.param_description: 

280 # Estimate lines for description 

281 desc_lines = self.param_description.split('\n') 

282 content_lines += len(desc_lines) + 1 # Description lines + margin 

283 

284 if self.param_type: 

285 content_lines += 1 # Type line 

286 

287 content_lines += 3 # Close button + margins 

288 min_height = max(min_height, content_lines) 

289 

290 # Cap maximum size to reasonable limits 

291 max_width = min(max_width, 100) 

292 min_height = min(min_height, 30) 

293 

294 # Set dynamic minimum size 

295 self.styles.min_width = max_width 

296 self.styles.min_height = min_height 

297 

298 except Exception: 

299 # Fallback to default sizes if calculation fails 

300 self.styles.min_width = 30 

301 self.styles.min_height = 8 

302 

303 def _format_type_info(self, param_type) -> str: 

304 """Format type information for display, showing full Union types.""" 

305 if not param_type: 

306 return "Unknown" 

307 

308 try: 

309 from typing import get_origin, get_args, Union 

310 

311 # Handle Union types (including Optional) 

312 origin = get_origin(param_type) 

313 if origin is Union: 

314 args = get_args(param_type) 

315 # Filter out NoneType for cleaner display 

316 non_none_args = [arg for arg in args if arg is not type(None)] 

317 

318 if len(non_none_args) == 1 and type(None) in args: 

319 # This is Optional[T] - show as "T (optional)" 

320 return f"{self._format_single_type(non_none_args[0])} (optional)" 

321 else: 

322 # This is a true Union - show all types 

323 type_names = [self._format_single_type(arg) for arg in args] 

324 return f"Union[{', '.join(type_names)}]" 

325 else: 

326 # Regular type 

327 return self._format_single_type(param_type) 

328 

329 except Exception: 

330 # Fallback to simple name if anything goes wrong 

331 return getattr(param_type, '__name__', str(param_type)) 

332 

333 def _format_single_type(self, type_obj) -> str: 

334 """Format a single type for display.""" 

335 try: 

336 from typing import get_origin, get_args 

337 

338 origin = get_origin(type_obj) 

339 if origin: 

340 # Handle generic types like List[str], Dict[str, int] 

341 args = get_args(type_obj) 

342 if args: 

343 arg_names = [self._format_single_type(arg) for arg in args] 

344 return f"{origin.__name__}[{', '.join(arg_names)}]" 

345 else: 

346 return origin.__name__ 

347 else: 

348 # Simple type 

349 return getattr(type_obj, '__name__', str(type_obj)) 

350 except Exception: 

351 return str(type_obj) 

352 

353 

354class HelpWindowManager: 

355 """Unified help window management system - consolidates all help window logic.""" 

356 

357 @staticmethod 

358 async def show_docstring_help(app, target: Union[Callable, type], title: Optional[str] = None): 

359 """Show help for a function or class using the window system.""" 

360 try: 

361 window = app.query_one(DocstringHelpWindow) 

362 # Window exists, update it and open 

363 window.target = target 

364 window.docstring_info = DocstringExtractor.extract(target) 

365 window.open_state = True 

366 except NoMatches: 

367 # Expected case: window doesn't exist yet, create new one 

368 window = DocstringHelpWindow(target, title) 

369 await app.mount(window) 

370 window.open_state = True 

371 

372 @staticmethod 

373 async def show_parameter_help(app, param_name: str, param_description: str, param_type: type = None): 

374 """Show help for a parameter using the window system.""" 

375 try: 

376 window = app.query_one(ParameterHelpWindow) 

377 # Window exists, update it and open 

378 window.param_name = param_name 

379 window.param_description = param_description 

380 window.param_type = param_type 

381 window.open_state = True 

382 except NoMatches: 

383 # Expected case: window doesn't exist yet, create new one 

384 window = ParameterHelpWindow(param_name, param_description, param_type) 

385 await app.mount(window) 

386 window.open_state = True 

387 

388 

389class HelpableWidget: 

390 """Mixin class to add help functionality to widgets - uses unified manager.""" 

391 

392 async def show_function_help(self, target: Union[Callable, type]) -> None: 

393 """Show help window for a function or class.""" 

394 if hasattr(self, 'app'): 

395 await HelpWindowManager.show_docstring_help(self.app, target) 

396 

397 async def show_parameter_help(self, param_name: str, param_description: str, param_type: type = None) -> None: 

398 """Show help window for a parameter.""" 

399 if hasattr(self, 'app'): 

400 await HelpWindowManager.show_parameter_help(self.app, param_name, param_description, param_type)