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
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""
2Reusable widgets for NexusLIMS TUI applications.
4Provides form inputs, validation feedback, and other common UI components.
5"""
7from collections.abc import Callable
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
18class NumpadInput(Input):
19 """
20 Input widget that accepts numpad key entry.
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 """
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 }
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
57class ValidatedInput(Input):
58 """
59 Input widget with validation support.
61 Displays validation errors below the input field and provides
62 custom messages for invalid states.
64 Attributes
65 ----------
66 validator : Callable | None
67 Validation function that takes the input value and returns
68 (is_valid, error_message) tuple
69 """
71 def __init__(
72 self,
73 *args,
74 validator: Callable | None = None,
75 **kwargs,
76 ):
77 """
78 Initialize ValidatedInput.
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)
97 def validate_value(self, value: str) -> tuple[bool, str]:
98 """
99 Validate the input value.
101 Parameters
102 ----------
103 value : str
104 Value to validate
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)
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
121 # Update visual state
122 if not is_valid:
123 self.add_class("error")
124 else:
125 self.remove_class("error")
127 @property
128 def is_valid(self) -> bool:
129 """Check if current value is valid."""
130 return self._is_valid
132 @property
133 def error_message(self) -> str:
134 """Get current error message."""
135 return self._error_message
138class AutocompleteInput(Input):
139 """
140 Input widget with autocomplete suggestions.
142 Uses Textual's built-in Suggester for dropdown suggestions.
144 Attributes
145 ----------
146 suggestions : list[str]
147 List of suggestion strings
148 """
150 def __init__(
151 self,
152 suggestions: list[str] | None = None,
153 *args,
154 **kwargs,
155 ):
156 """
157 Initialize AutocompleteInput.
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 []
170 # Create custom suggester
171 if self._suggestions:
172 suggester = _ListSuggester(self._suggestions)
173 kwargs["suggester"] = suggester
175 super().__init__(*args, **kwargs)
177 def set_suggestions(self, suggestions: list[str]) -> None:
178 """
179 Update autocomplete suggestions.
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
190class _ListSuggester(Suggester):
191 """Internal suggester for AutocompleteInput."""
193 def __init__(self, suggestions: list[str]):
194 """Initialize with suggestion list."""
195 super().__init__()
196 self.suggestions = suggestions
198 async def get_suggestion(self, value: str) -> str | None:
199 """Get suggestion for current input value."""
200 if not value:
201 return None
203 value_lower = value.lower()
204 for suggestion in self.suggestions:
205 if suggestion.lower().startswith(value_lower):
206 return suggestion
208 return None
211class FormField(Vertical):
212 """
213 Container for a labeled form field with validation error display.
215 Provides consistent layout for label + input + error message.
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 """
227 class Changed(Message):
228 """Message emitted when field value changes."""
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
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.
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
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 += " *"
274 yield Label(label, classes="field-label")
276 if self.help_text:
277 yield Static(self.help_text, classes="field-help")
279 yield self.input_widget
281 # Error message placeholder
282 yield Static("", classes="field-error", id=f"{self.input_widget.id}-error")
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")
297 # Emit changed message
298 self.post_message(self.Changed(self, event.value))
300 @property
301 def value(self) -> str:
302 """Get current field value."""
303 return self.input_widget.value
305 @value.setter
306 def value(self, value: str) -> None:
307 """Set field value."""
308 self.input_widget.value = value
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
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 ""