Source code for nexusLIMS.tui.common.widgets
"""
Reusable widgets for NexusLIMS TUI applications.
Provides form inputs, validation feedback, and other common UI components.
"""
from collections.abc import Callable
from textual import on
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.events import Key
from textual.message import Message
from textual.suggester import Suggester
from textual.widgets import Input, Label, Static
[docs]
class NumpadInput(Input):
"""
Input widget that accepts numpad key entry.
Extends Textual's Input to map numpad keys to their regular equivalents,
fixing the issue where numpad minus and other numpad keys don't work.
"""
[docs]
def on_key(self, event: Key) -> None:
"""Handle numpad key events before default Input processing."""
# Map numpad keys to regular keys - using actual key names from Textual
numpad_map = {
# Actual Textual key names (as shown in debug output)
"subtract": "-",
"add": "+",
"divide": "/",
"multiply": "*",
"decimal": ".",
# Numeric keys (if needed)
"numpad_0": "0",
"numpad_1": "1",
"numpad_2": "2",
"numpad_3": "3",
"numpad_4": "4",
"numpad_5": "5",
"numpad_6": "6",
"numpad_7": "7",
"numpad_8": "8",
"numpad_9": "9",
}
if event.key in numpad_map:
# Insert the mapped character
self.insert_text_at_cursor(numpad_map[event.key])
event.prevent_default()
event.stop()
# Otherwise let default Input handling process the key
[docs]
class ValidatedInput(Input):
"""
Input widget with validation support.
Displays validation errors below the input field and provides
custom messages for invalid states.
Attributes
----------
validator : Callable | None
Validation function that takes the input value and returns
(is_valid, error_message) tuple
"""
def __init__(
self,
*args,
validator: Callable | None = None,
**kwargs,
):
"""
Initialize ValidatedInput.
Parameters
----------
validator : Callable | None
Validation function: (value: str) -> (bool, str)
*args
Positional arguments passed to Input
**kwargs
Keyword arguments passed to Input
"""
self._validator = validator
self._is_valid = True
self._error_message = ""
# Disable Textual's built-in validation to avoid conflicts
kwargs["validators"] = None
kwargs["validate_on"] = []
super().__init__(*args, **kwargs)
[docs]
def validate_value(self, value: str) -> tuple[bool, str]:
"""
Validate the input value.
Parameters
----------
value : str
Value to validate
Returns
-------
tuple[bool, str]
(is_valid, error_message)
"""
if self._validator is None:
return True, ""
return self._validator(value)
def _watch_value(self, value: str) -> None:
"""Watch for value changes and validate."""
is_valid, error = self.validate_value(value)
self._is_valid = is_valid
self._error_message = error
# Update visual state
if not is_valid:
self.add_class("error")
else:
self.remove_class("error")
@property
def is_valid(self) -> bool:
"""Check if current value is valid."""
return self._is_valid
@property
def error_message(self) -> str:
"""Get current error message."""
return self._error_message
[docs]
class AutocompleteInput(Input):
"""
Input widget with autocomplete suggestions.
Uses Textual's built-in Suggester for dropdown suggestions.
Attributes
----------
suggestions : list[str]
List of suggestion strings
"""
def __init__(
self,
suggestions: list[str] | None = None,
*args,
**kwargs,
):
"""
Initialize AutocompleteInput.
Parameters
----------
suggestions : list[str] | None
List of autocomplete suggestions
*args
Positional arguments passed to Input
**kwargs
Keyword arguments passed to Input
"""
self._suggestions = suggestions or []
# Create custom suggester
if self._suggestions:
suggester = _ListSuggester(self._suggestions)
kwargs["suggester"] = suggester
super().__init__(*args, **kwargs)
[docs]
def set_suggestions(self, suggestions: list[str]) -> None:
"""
Update autocomplete suggestions.
Parameters
----------
suggestions : list[str]
New list of suggestions
"""
self._suggestions = suggestions
self.suggester = _ListSuggester(suggestions) if suggestions else None
class _ListSuggester(Suggester):
"""Internal suggester for AutocompleteInput."""
def __init__(self, suggestions: list[str]):
"""Initialize with suggestion list."""
super().__init__()
self.suggestions = suggestions
async def get_suggestion(self, value: str) -> str | None:
"""Get suggestion for current input value."""
if not value:
return None
value_lower = value.lower()
for suggestion in self.suggestions:
if suggestion.lower().startswith(value_lower):
return suggestion
return None
[docs]
class FormField(Vertical):
"""
Container for a labeled form field with validation error display.
Provides consistent layout for label + input + error message.
Attributes
----------
label_text : str
Field label text
input_widget : textual.widgets.Input
Input widget (ValidatedInput, AutocompleteInput, etc.)
required : bool
Whether field is required
"""
[docs]
class Changed(Message):
"""Message emitted when field value changes."""
def __init__(self, field: "FormField", value: str) -> None:
"""Initialize message with field and value."""
super().__init__()
self.field = field
self.value = value
def __init__(
self,
label_text: str,
input_widget: Input,
*,
required: bool = False,
help_text: str | None = None,
**kwargs,
):
"""
Initialize FormField.
Parameters
----------
label_text : str
Label text for the field
input_widget : textual.widgets.Input
Input widget to use
required : bool
Whether field is required
help_text : str | None
Optional help text shown below label
**kwargs
Additional arguments passed to Vertical
"""
super().__init__(**kwargs)
self.label_text = label_text
self.input_widget = input_widget
self.required = required
self.help_text = help_text
[docs]
def compose(self) -> ComposeResult:
"""Compose the field layout."""
# Label with required indicator
label = self.label_text
if self.required:
label += " *"
yield Label(label, classes="field-label")
if self.help_text:
yield Static(self.help_text, classes="field-help")
yield self.input_widget
# Error message placeholder
yield Static("", classes="field-error", id=f"{self.input_widget.id}-error")
[docs]
@on(Input.Changed)
def on_input_changed(self, event: Input.Changed) -> None:
"""Forward input changes and update error display."""
# Update error display if input is ValidatedInput
if isinstance(self.input_widget, ValidatedInput):
error_static = self.query_one(f"#{self.input_widget.id}-error", Static)
if self.input_widget.is_valid:
error_static.update("")
error_static.remove_class("visible")
else:
error_static.update(self.input_widget.error_message)
error_static.add_class("visible")
# Emit changed message
self.post_message(self.Changed(self, event.value))
@property
def value(self) -> str:
"""Get current field value."""
return self.input_widget.value
@value.setter
def value(self, value: str) -> None:
"""Set field value."""
self.input_widget.value = value
@property
def is_valid(self) -> bool:
"""Check if field value is valid."""
if isinstance(self.input_widget, ValidatedInput):
return self.input_widget.is_valid
return True
@property
def error_message(self) -> str:
"""Get current error message."""
if isinstance(self.input_widget, ValidatedInput):
return self.input_widget.error_message
return ""