Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment on lines +21 to 24
### 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
Expand Down Expand Up @@ -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.
Expand Down
231 changes: 217 additions & 14 deletions paramui/paramui.py → paramui.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import types
import time
import sys
import re

# Embed chibiui code instead of importing chibiui

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'."""
Expand Down Expand Up @@ -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('<FocusOut>', 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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion paramui/__init__.py

This file was deleted.

15 changes: 0 additions & 15 deletions setup.py

This file was deleted.