diff --git a/README.md b/README.md index 746844a..750daee 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,25 @@ ParamUI is a Python framework for easy parameter management and GUI creation. It - Parameters are mapped to a nested Prm structure - Tree navigation using path syntax - Supports slider, selector, button, checkbox, file browser, and textbox widgets +- Regex pattern validation for textbox inputs - Scrollable UI for large parameter sets +- Navigation tree auto-minimizes when the parameter hierarchy has no sub-levels - Headless mode is supported for testing without a GUI ## 📦 Installation -### Method 1: Install using pip: +### Supported Environment +- OS: Windows, Linux +- Python: 3.7 or later + +### Method 1: Download to the current folder using wget: ```bash -pip install git+https://github.com/covao/ParamUI +wget https://raw.githubusercontent.com/covao/ParamUI/main/paramui/paramui.py ``` ### Method 2: Download the source code Download [paramui.py](./paramui/paramui.py) and place it in your project directory ### Uninstall -```bash -pip uninstall paramui -``` +Delete `paramui.py` from your project directory. ## 📋 Example ~~~python @@ -61,12 +65,26 @@ ParameterTable = [ ['Run', 'Run!', False, 'button'], # Button ['Flag', 'Flag 1', True, []], # Checkbox ['Text', 'Text Input', 'ABC', []], # Textbox + ['Name', 'Name', 'Taro', '[A-Za-z ]{0,32}'], # Textbox with regex pattern validation ['Options/File', 'File Path', '*.txt', 'file'], # File browser ['Options/Selector', 'Select Option', 'Option1', ['Option1', 'Option2', 'Option3']], # Selector with options # Add more parameters as needed ] ``` + +### Regex pattern validation for textboxes +If a regex pattern string (e.g. `'[A-Za-z ]{0,32}'`) is given as the Range/options field for a string-valued +parameter, the parameter is rendered as a textbox whose value is validated against that pattern with `re.fullmatch`. +- While typing, the text is checked on every keystroke; the text color turns **red** whenever it doesn't match + the pattern, and back to normal once it matches again. +- When the textbox loses focus (or focus moves to another UI element) while the text still doesn't match the + pattern, the textbox reverts to its last valid (current) value, and the invalid text is never written to `Prm`. +## Navigation Tree +ParamUI shows a navigation tree on the left for parameters organized under sub-paths (e.g. `Options/Flag1`). +If every parameter in the table is at the Root level (no `/` in any parameter path), the navigation tree is +not needed, so it is automatically minimized to the left edge of the window. + ## API Reference - paramui(parameter_table, show_ui=True): Create the parameter UI and structure. If `show_ui` is False, runs in headless mode. - close_ui(): Close the UI window. diff --git a/paramui/paramui.py b/paramui.py similarity index 77% rename from paramui/paramui.py rename to paramui.py index 6fa805c..a329599 100644 --- a/paramui/paramui.py +++ b/paramui.py @@ -1,6 +1,7 @@ import types import time import sys +import re # Embed chibiui code instead of importing chibiui @@ -696,6 +697,10 @@ class paramui: - File Browser: '*.txt;*.doc' - Checkbox: [] (empty list) - Textbox: [] (default for strings) + - Textbox with regex validation: a regex pattern string, e.g. '[A-Za-z ]{0,32}' + The value is only committed when it fully matches the pattern. + When the textbox loses focus with a non-matching value, it reverts + to the last valid value instead. Parameter Structure: - Prm.(ParameterVariable): Nested structure based on ParameterVariable paths @@ -734,6 +739,10 @@ def __init__(self, parameter_table=[], show_ui=True): self.ShowUI = show_ui self.ui = None self.widget_paths = {} # Map parameter variables to widget paths + self.patterns = {} # Map parameter variables to compiled regex patterns (textbox validation) + self.last_valid_value = {} # Map parameter variables to their last value that matched the pattern + self.pattern_entries = {} # Map parameter variables to (Entry widget, StringVar) for live color feedback + self.pattern_vars = {} # Map parameter variables to their textbox's StringVar if parameter_table and self.ShowUI and chibiui: # Create the UI using chibiui @@ -744,24 +753,35 @@ def __init__(self, parameter_table=[], show_ui=True): # Set the close protocol to handle window close events self.ui.root.protocol("WM_DELETE_WINDOW", self.close_ui) - - # Create widgets for each parameter - for i, param in enumerate(parameter_table): - variable, label, initial_value, step = param - # Create nested structure for path-like variables - self._create_nested_structure(variable, initial_value) - - # Determine the widget path for chibiui based on the variable and label. - # Use variable name as widget path for direct access compatibility + + # First pass: compute widget paths and register regex patterns + # for every parameter before any widget is created, so that the + # FocusOut validation (see _setup_pattern_validation) is already + # wired up when the very first textbox gets rendered. + for variable, label, initial_value, step in parameter_table: parts = variable.split('/') if len(parts) > 1: - # For nested paths like 'Options/Flag', use variable path but with ParameterLabel widget_path = '/'.join(parts[:-1]) + '/' + label else: - # For root level like 'Run', use ParameterLabel directly widget_path = label self.widget_paths[variable] = widget_path - + + if (isinstance(initial_value, str) and isinstance(step, str) and step + and not (step.startswith('*.') or step == 'folder')): + self._register_pattern(variable, initial_value, step) + + # Bind pattern validation to textboxes so invalid values are + # rejected when the user leaves the field (composition-based + # instrumentation only; chibiui's class code is left untouched) + self._setup_pattern_validation() + + # Create widgets for each parameter + for i, param in enumerate(parameter_table): + variable, label, initial_value, step = param + # Create nested structure for path-like variables + self._create_nested_structure(variable, initial_value) + widget_path = self.widget_paths[variable] + # Create appropriate widget based on parameter type if isinstance(initial_value, (int, float)) and isinstance(step, list) and len(step) == 3: # Slider widget @@ -785,11 +805,21 @@ def __init__(self, parameter_table=[], show_ui=True): elif isinstance(step, list) and step: # Dropdown selector widget self.ui.add_selector(widget_path, step, initial_value) - + + elif isinstance(initial_value, str) and isinstance(step, str) and step: + # Text box widget with regex pattern validation + # (pattern already registered in the first pass above) + self.ui.add_textbox(widget_path, str(initial_value)) + else: # Text box widget (default) self.ui.add_textbox(widget_path, str(initial_value)) + # If the parameter hierarchy is flat (Root level only), minimize + # the navigation tree window to the left edge since it's unused + if not any('/' in param[0] for param in parameter_table): + self._minimize_nav_tree() + # UI is now ready elif parameter_table: # Initialize parameters without UI @@ -811,6 +841,9 @@ def _init_parameters_only(self, parameter_table): variable, label, initial_value, step = param # Create nested structure for path-like variables self._create_nested_structure(variable, initial_value) + # Register regex pattern (if any) for later validation in update_prm() + if isinstance(step, str) and step and not (step.startswith('*.') or step == 'folder' or step == 'button'): + self._register_pattern(variable, initial_value, step) def _create_nested_structure(self, path, value): """Create nested structure in Prm for path like 'Person/Name'.""" @@ -860,6 +893,164 @@ def update_parameter(self, variable, value): except Exception: pass + def _register_pattern(self, variable, initial_value, step): + """ + Compile and register a regex pattern for a textbox parameter. + + Args: + variable (str): The parameter path (e.g. 'Name1', 'Options/Text1'). + initial_value (str): The initial value of the parameter. + step (str): The regex pattern string (e.g. '[A-Za-z ]{0,32}'). + """ + try: + self.patterns[variable] = re.compile(step) + self.last_valid_value[variable] = initial_value + except re.error as e: + print(f"Warning: invalid regex pattern for '{variable}': {step} ({e})") + + def _setup_pattern_validation(self): + """ + Attach live regex validation to textbox widgets that have a pattern. + + While typing, the entered text is checked against the pattern on + every keystroke and the text color turns red when it doesn't match + (green/default otherwise). When the field loses focus (or focus + moves to another widget) with a non-matching value, the textbox is + reverted to its last valid value. + + This does NOT modify the chibiui class itself: it wraps the + `_create_textbox` method on the specific chibiui *instance* created + for this paramui (composition/instrumentation), so the pattern is + checked whenever a textbox is (re)created, and the resulting Entry + widget is bound to validate its value as it's typed and on focus-out. + """ + if not self.patterns or not self.ui or self.ui.nogui: + return + + # Build a lookup from chibiui's internal widget key ("/path/Label") + # back to the original parameter variable name. + key_to_variable = {} + for variable, widget_path in self.widget_paths.items(): + if variable in self.patterns: + key = widget_path if widget_path.startswith('/') else '/' + widget_path + key_to_variable[key] = variable + + ui = self.ui + original_create_textbox = ui._create_textbox + + def patched_create_textbox(label, value): + original_create_textbox(label, value) + try: + full_key = ui._get_full_key(ui.current_path, label) + variable = key_to_variable.get(full_key) + if variable is None: + return + frame = ui._get_current_frame() + children = frame.winfo_children() + if not children: + return + subframe = children[-1] + entry = None + for w in subframe.winfo_children(): + if w.winfo_class() == 'Entry': + entry = w + break + if entry is None: + return + + self.pattern_entries[variable] = entry + # Live validation: color the text red while it doesn't match + entry_var = ui.value.get(full_key) + self.pattern_vars[variable] = entry_var + self._apply_pattern_color(variable, entry, text=entry_var.get() if entry_var is not None else None) + if entry_var is not None: + entry_var.trace_add('write', lambda *a, v=variable: self._on_pattern_text_changed(v)) + + # Revert to the last valid value when the field (or focus) + # is left with a non-matching value + entry.bind('', lambda e, v=variable: self._validate_pattern_field(v)) + except Exception: + pass + + # Instance-level override; chibiui's class definition is unchanged. + ui._create_textbox = patched_create_textbox + + def _apply_pattern_color(self, variable, entry, text=None): + """Set the textbox's text color based on whether it matches the pattern.""" + try: + if text is None: + text = entry.get() + if self.patterns[variable].fullmatch(str(text)): + entry.config(fg='black') + else: + entry.config(fg='red') + except Exception: + pass + + def _on_pattern_text_changed(self, variable): + """Called on every keystroke in a pattern-validated textbox to update its color live.""" + entry = self.pattern_entries.get(variable) + entry_var = self.pattern_vars.get(variable) + if entry is None or entry_var is None or not self.ui or not self.IsAlive: + return + self._apply_pattern_color(variable, entry, text=entry_var.get()) + + def _validate_pattern_field(self, variable): + """ + Validate a textbox's current UI value against its registered regex + pattern. Called when the textbox loses focus (or focus moves to + another widget). If the value doesn't fully match the pattern, the + textbox is reverted to the last valid (current) value instead of + being committed. + + Args: + variable (str): The parameter path this textbox belongs to. + """ + if not self.ui or not self.IsAlive or variable not in self.patterns: + return + path = self.widget_paths.get(variable) + if path is None: + return + try: + current_value = self.ui.get(path) + if current_value is None: + return + if self.patterns[variable].fullmatch(str(current_value)): + self.last_valid_value[variable] = current_value + else: + last_valid = self.last_valid_value.get(variable, self._get_nested_value(variable)) + self.ui.set(path, last_valid) + entry = self.pattern_entries.get(variable) + if entry is not None: + self._apply_pattern_color(variable, entry, text=self.last_valid_value.get(variable)) + except Exception: + pass + + def _minimize_nav_tree(self): + """ + Minimize the navigation tree panel to the left edge. Used when the + parameter hierarchy only has the Root level, since the tree isn't + needed for navigation in that case. + + Implemented purely through the tkinter widget references already + exposed by chibiui (self.ui.root, self.ui.nav_tree) - the chibiui + class itself is not modified. + """ + if not self.ui or self.ui.nogui or not self.ui.root: + return + + def _do_minimize(): + try: + nav_tree = self.ui.nav_tree + nav_frame = nav_tree.master + main_paned = nav_frame.master + main_paned.paneconfigure(nav_frame, width=1) + main_paned.sash_place(0, 1, 0) + except Exception: + pass + + self.ui.root.after(0, _do_minimize) + def update_prm(self): """Update parameters from UI values. After sync, auto-reset all UI buttons to False.""" if not self.Prm or not self.ui or not self.IsAlive: @@ -871,6 +1062,18 @@ def update_prm(self): ui_value = self.ui.get(path) if ui_value is None: continue + if variable in self.patterns: + if not self.patterns[variable].fullmatch(str(ui_value)): + # Value doesn't match the pattern. + # GUI mode: the FocusOut handler (see _setup_pattern_validation) + # already reverts the textbox when the user leaves the field, + # so just skip committing this value to Prm while it's invalid. + # Headless mode has no focus events, so revert immediately here. + if self.ui.nogui: + last_valid = self.last_valid_value.get(variable, self._get_nested_value(variable)) + self.ui.set(path, last_valid) + continue + self.last_valid_value[variable] = ui_value current_value = self._get_nested_value(variable) if ui_value != current_value: self._set_nested_value(variable, ui_value) @@ -918,7 +1121,7 @@ def flush(self): ['Options/Flag1','Flag 1',True,[]], # pu.Prm.Options.Flag1 ['Run','Run!',False,'button'], # pu.Prm.Run ['Options/Select1','Select 1','Two',['One','Two','Three']], # pu.Prm.Options.Select1 - ['Name1','Name 1','Taro',[]] # pu.Prm.Name1 + ['Name1','Name 1','Taro','[A-Za-z ]{0,32}'] # pu.Prm.Name1 (letters/spaces only, up to 32 chars) ] # Create paramui instance diff --git a/paramui/__init__.py b/paramui/__init__.py deleted file mode 100644 index d930c6b..0000000 --- a/paramui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from paramui.paramui import paramui \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 24ee791..0000000 --- a/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name='paramui', - version='2.0.0', - author='covao, Koichi Koabayshi', - license='MIT', - author_email='', - url='https://github.com/covao/ParamUI', - description='Create UI from Parameter Table', - packages=find_packages(), - python_requires='>=3.7', - install_requires=[ - ] -)