Coverage for nexusLIMS/tui/common/widgets.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1""" 

2Reusable widgets for NexusLIMS TUI applications. 

3 

4Provides form inputs, validation feedback, and other common UI components. 

5""" 

6 

7from collections.abc import Callable 

8 

9from textual import on 

10from textual.app import ComposeResult 

11from textual.containers import Vertical 

12from textual.events import Key 

13from textual.message import Message 

14from textual.suggester import Suggester 

15from textual.widgets import Input, Label, Static 

16 

17 

18class NumpadInput(Input): 

19 """ 

20 Input widget that accepts numpad key entry. 

21 

22 Extends Textual's Input to map numpad keys to their regular equivalents, 

23 fixing the issue where numpad minus and other numpad keys don't work. 

24 """ 

25 

26 def on_key(self, event: Key) -> None: 

27 """Handle numpad key events before default Input processing.""" 

28 # Map numpad keys to regular keys - using actual key names from Textual 

29 numpad_map = { 

30 # Actual Textual key names (as shown in debug output) 

31 "subtract": "-", 

32 "add": "+", 

33 "divide": "/", 

34 "multiply": "*", 

35 "decimal": ".", 

36 # Numeric keys (if needed) 

37 "numpad_0": "0", 

38 "numpad_1": "1", 

39 "numpad_2": "2", 

40 "numpad_3": "3", 

41 "numpad_4": "4", 

42 "numpad_5": "5", 

43 "numpad_6": "6", 

44 "numpad_7": "7", 

45 "numpad_8": "8", 

46 "numpad_9": "9", 

47 } 

48 

49 if event.key in numpad_map: 

50 # Insert the mapped character 

51 self.insert_text_at_cursor(numpad_map[event.key]) 

52 event.prevent_default() 

53 event.stop() 

54 # Otherwise let default Input handling process the key 

55 

56 

57class ValidatedInput(Input): 

58 """ 

59 Input widget with validation support. 

60 

61 Displays validation errors below the input field and provides 

62 custom messages for invalid states. 

63 

64 Attributes 

65 ---------- 

66 validator : Callable | None 

67 Validation function that takes the input value and returns 

68 (is_valid, error_message) tuple 

69 """ 

70 

71 def __init__( 

72 self, 

73 *args, 

74 validator: Callable | None = None, 

75 **kwargs, 

76 ): 

77 """ 

78 Initialize ValidatedInput. 

79 

80 Parameters 

81 ---------- 

82 validator : Callable | None 

83 Validation function: (value: str) -> (bool, str) 

84 *args 

85 Positional arguments passed to Input 

86 **kwargs 

87 Keyword arguments passed to Input 

88 """ 

89 self._validator = validator 

90 self._is_valid = True 

91 self._error_message = "" 

92 # Disable Textual's built-in validation to avoid conflicts 

93 kwargs["validators"] = None 

94 kwargs["validate_on"] = [] 

95 super().__init__(*args, **kwargs) 

96 

97 def validate_value(self, value: str) -> tuple[bool, str]: 

98 """ 

99 Validate the input value. 

100 

101 Parameters 

102 ---------- 

103 value : str 

104 Value to validate 

105 

106 Returns 

107 ------- 

108 tuple[bool, str] 

109 (is_valid, error_message) 

110 """ 

111 if self._validator is None: 

112 return True, "" 

113 return self._validator(value) 

114 

115 def _watch_value(self, value: str) -> None: 

116 """Watch for value changes and validate.""" 

117 is_valid, error = self.validate_value(value) 

118 self._is_valid = is_valid 

119 self._error_message = error 

120 

121 # Update visual state 

122 if not is_valid: 

123 self.add_class("error") 

124 else: 

125 self.remove_class("error") 

126 

127 @property 

128 def is_valid(self) -> bool: 

129 """Check if current value is valid.""" 

130 return self._is_valid 

131 

132 @property 

133 def error_message(self) -> str: 

134 """Get current error message.""" 

135 return self._error_message 

136 

137 

138class AutocompleteInput(Input): 

139 """ 

140 Input widget with autocomplete suggestions. 

141 

142 Uses Textual's built-in Suggester for dropdown suggestions. 

143 

144 Attributes 

145 ---------- 

146 suggestions : list[str] 

147 List of suggestion strings 

148 """ 

149 

150 def __init__( 

151 self, 

152 suggestions: list[str] | None = None, 

153 *args, 

154 **kwargs, 

155 ): 

156 """ 

157 Initialize AutocompleteInput. 

158 

159 Parameters 

160 ---------- 

161 suggestions : list[str] | None 

162 List of autocomplete suggestions 

163 *args 

164 Positional arguments passed to Input 

165 **kwargs 

166 Keyword arguments passed to Input 

167 """ 

168 self._suggestions = suggestions or [] 

169 

170 # Create custom suggester 

171 if self._suggestions: 

172 suggester = _ListSuggester(self._suggestions) 

173 kwargs["suggester"] = suggester 

174 

175 super().__init__(*args, **kwargs) 

176 

177 def set_suggestions(self, suggestions: list[str]) -> None: 

178 """ 

179 Update autocomplete suggestions. 

180 

181 Parameters 

182 ---------- 

183 suggestions : list[str] 

184 New list of suggestions 

185 """ 

186 self._suggestions = suggestions 

187 self.suggester = _ListSuggester(suggestions) if suggestions else None 

188 

189 

190class _ListSuggester(Suggester): 

191 """Internal suggester for AutocompleteInput.""" 

192 

193 def __init__(self, suggestions: list[str]): 

194 """Initialize with suggestion list.""" 

195 super().__init__() 

196 self.suggestions = suggestions 

197 

198 async def get_suggestion(self, value: str) -> str | None: 

199 """Get suggestion for current input value.""" 

200 if not value: 

201 return None 

202 

203 value_lower = value.lower() 

204 for suggestion in self.suggestions: 

205 if suggestion.lower().startswith(value_lower): 

206 return suggestion 

207 

208 return None 

209 

210 

211class FormField(Vertical): 

212 """ 

213 Container for a labeled form field with validation error display. 

214 

215 Provides consistent layout for label + input + error message. 

216 

217 Attributes 

218 ---------- 

219 label_text : str 

220 Field label text 

221 input_widget : textual.widgets.Input 

222 Input widget (ValidatedInput, AutocompleteInput, etc.) 

223 required : bool 

224 Whether field is required 

225 """ 

226 

227 class Changed(Message): 

228 """Message emitted when field value changes.""" 

229 

230 def __init__(self, field: "FormField", value: str) -> None: 

231 """Initialize message with field and value.""" 

232 super().__init__() 

233 self.field = field 

234 self.value = value 

235 

236 def __init__( 

237 self, 

238 label_text: str, 

239 input_widget: Input, 

240 *, 

241 required: bool = False, 

242 help_text: str | None = None, 

243 **kwargs, 

244 ): 

245 """ 

246 Initialize FormField. 

247 

248 Parameters 

249 ---------- 

250 label_text : str 

251 Label text for the field 

252 input_widget : textual.widgets.Input 

253 Input widget to use 

254 required : bool 

255 Whether field is required 

256 help_text : str | None 

257 Optional help text shown below label 

258 **kwargs 

259 Additional arguments passed to Vertical 

260 """ 

261 super().__init__(**kwargs) 

262 self.label_text = label_text 

263 self.input_widget = input_widget 

264 self.required = required 

265 self.help_text = help_text 

266 

267 def compose(self) -> ComposeResult: 

268 """Compose the field layout.""" 

269 # Label with required indicator 

270 label = self.label_text 

271 if self.required: 

272 label += " *" 

273 

274 yield Label(label, classes="field-label") 

275 

276 if self.help_text: 

277 yield Static(self.help_text, classes="field-help") 

278 

279 yield self.input_widget 

280 

281 # Error message placeholder 

282 yield Static("", classes="field-error", id=f"{self.input_widget.id}-error") 

283 

284 @on(Input.Changed) 

285 def on_input_changed(self, event: Input.Changed) -> None: 

286 """Forward input changes and update error display.""" 

287 # Update error display if input is ValidatedInput 

288 if isinstance(self.input_widget, ValidatedInput): 

289 error_static = self.query_one(f"#{self.input_widget.id}-error", Static) 

290 if self.input_widget.is_valid: 

291 error_static.update("") 

292 error_static.remove_class("visible") 

293 else: 

294 error_static.update(self.input_widget.error_message) 

295 error_static.add_class("visible") 

296 

297 # Emit changed message 

298 self.post_message(self.Changed(self, event.value)) 

299 

300 @property 

301 def value(self) -> str: 

302 """Get current field value.""" 

303 return self.input_widget.value 

304 

305 @value.setter 

306 def value(self, value: str) -> None: 

307 """Set field value.""" 

308 self.input_widget.value = value 

309 

310 @property 

311 def is_valid(self) -> bool: 

312 """Check if field value is valid.""" 

313 if isinstance(self.input_widget, ValidatedInput): 

314 return self.input_widget.is_valid 

315 return True 

316 

317 @property 

318 def error_message(self) -> str: 

319 """Get current error message.""" 

320 if isinstance(self.input_widget, ValidatedInput): 

321 return self.input_widget.error_message 

322 return ""