diff --git a/src/pymodaq_gui/examples/parameter_ex.py b/src/pymodaq_gui/examples/parameter_ex.py index 80840523..9bceaf61 100644 --- a/src/pymodaq_gui/examples/parameter_ex.py +++ b/src/pymodaq_gui/examples/parameter_ex.py @@ -10,6 +10,16 @@ from pymodaq_gui.utils.utils import create_nested_menu from pymodaq_gui.parameter.pymodaq_ptypes import GroupParameter, registerParameterType from pymodaq_gui.managers.parameter_manager import ParameterManager +from pymodaq_gui.utils.widgets.delegates import ( + NumericDelegate, + ComboBoxDelegate, + ColumnSpecificDelegate, + PatternCompleterDelegate, + BooleanDelegate, + SpinBoxDelegate, +) +from pyqtgraph.parametertree import ParameterTree, Parameter + class ScalableGroup(GroupParameter): def __init__(self, **opts): @@ -23,7 +33,86 @@ def addNew(self, full_path:tuple): # Need to register a new type to properly trigger addNew registerParameterType('groupedit', ScalableGroup, override=True) +def create_comprehensive_table(): + """ + Create a table showcasing all delegate types: + - Column 0: Plain text (no delegate) + - Column 1: Numeric spinbox (0-100) + - Column 2: ComboBox dropdown + - Column 3: Pattern completer (@mentions, #tags) + - Column 4: Numeric with decimals (0-10, 2 decimals) + - Column 5: Boolean checkbox + """ + + # Setup pattern completer delegate + pattern_delegate = PatternCompleterDelegate() + pattern_delegate.add_completer("@", ["Alice", "Bob", "Charlie", "David", "Eve"]) + pattern_delegate.add_completer( + "#", ["important", "urgent", "review", "done", "todo"] + ) + + # Create column-specific delegate + delegate = ColumnSpecificDelegate( + { + 1: NumericDelegate(min_val=0, max_val=100, decimals=0), + 2: ComboBoxDelegate(["Type A", "Type B", "Type C", "Type D"]), + 3: pattern_delegate, + 4: SpinBoxDelegate(decimals=4, min=-1e6, max=1e6, units="s"), + 5: BooleanDelegate(), + } + ) + + table_params = { + "title": "Multi-Delegate Table", + "name": "multi_delegate_table", + "type": "table", + "columns": [ + "Name (Plain)", + "Score (NumericDelegate 0-100)", + "Status (ComboBoxDelegate)", + "Tags (PatternDelegate @/#)", + "Rating (SpinBoxDelegate 0-10)", + "Checked (BooleanDelegate True/False)", + ], + "rows": 5, + "delegate": lambda: delegate, + "max_display_rows": 6, + "value": [ + ["Sample 1", "75", "Type A", "@Alice #important", "8.5s", True], + ["Sample 2", "50", "Type B", "@Bob #urgent", "7.2s", True], + ["Sample 3", "90", "Type C", "@Charlie #review", "9.1s", False], + ["Sample 4", "60", "Type A", "", "6.8s"], + ["Sample 5", "", "", "", "", ""], + ], + "enable_row_controls": True, + } + + return table_params + # return Parameter.create(name="params", type="group", children=params) +def create_text_parameter(): + text_params = { + "name": "Text Editing with pattern completion", + "type": "group", + "children": [ + { + "name": "Message", + "type": "text_pattern", + "value": "", + "patterns": { + "@": ["alice", "bob", "charlie"], + "#": ["python", "javascript", "cpp"], + }, + "completer_config": { + "min_width": 200, + "max_width": 400, + "case_sensitive": False, + "visual_indicator": True, + }, + }, + ], + } + return text_params class ParameterEx(ParameterManager): params = [ {'title': 'Groups:', 'name': 'groups', 'type': 'group', 'children': [ @@ -117,13 +206,15 @@ class ParameterEx(ParameterManager): {'title': 'Plain text:', 'name': 'texts', 'type': 'group', 'children': [ {'title': 'Standard str', 'name': 'atte', 'type': 'str', 'value': 'this is a string you can edit'}, {'title': 'Plain text', 'name': 'text', 'type': 'text', 'value': 'this is some text'}, + create_text_parameter(), {'title': 'Plain text', 'name': 'textpb', 'type': 'text_pb', 'value': 'this is some text', 'tip': 'If text_pb type is used, user can add text to the parameter'}, ]}, {'title': 'Tables:', 'name': 'tables', 'type': 'group', 'children': [ - {'title': 'Table widget', 'name': 'tablewidget', 'type': 'table', 'value': + {'title': 'Table widget', 'name': 'tablewidget', 'type': 'table_dict', 'value': OrderedDict(key1='data1', key2=24), 'header': ['keys', 'limits'], 'height': 100}, + create_comprehensive_table(), {'title': 'Table view', 'name': 'tabular_table', 'type': 'table_view', 'delegate': table.SpinBoxDelegate, 'menu': True, 'value': table.TableModel([[0.1, 0.2, 0.3]], ['value1', 'value2', 'value3']), diff --git a/src/pymodaq_gui/managers/roi_manager.py b/src/pymodaq_gui/managers/roi_manager.py index aec50155..cc5f9d93 100644 --- a/src/pymodaq_gui/managers/roi_manager.py +++ b/src/pymodaq_gui/managers/roi_manager.py @@ -207,7 +207,7 @@ def setupUI(self): self.roiwidget.setMaximumWidth(300) params = [ - {'title': 'Measurements:', 'name': 'measurements', 'type': 'table', 'value': OrderedDict([]), 'Ncol': 2, + {'title': 'Measurements:', 'name': 'measurements', 'type': 'table_dict', 'value': OrderedDict([]), 'Ncol': 2, 'header': ["LO", "Value"]}, ROIScalableGroup(roi_type=self.ROI_type, name="ROIs")] self.settings = Parameter.create(title='ROIs Settings', name='rois_settings', type='group', children=params) diff --git a/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py b/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py index 9b76874d..1ebef6f7 100644 --- a/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py @@ -1,11 +1,15 @@ from pyqtgraph.parametertree.parameterTypes.basetypes import ( - SimpleParameter, GroupParameter, GroupParameterItem) # to be imported from elsewhere + SimpleParameter, + GroupParameter, + GroupParameterItem, +) # to be imported from elsewhere from .bool import BoolPushParameter from .pixmap import PixmapParameter, PixmapCheckParameter from .slide import SliderSpinBox, SliderParameter from .led import LedPushParameter, LedParameter from .date import DateParameter, DateTimeParameter, TimeParameter from .list import ListParameter +from .table_dict import DoubleColumnTableParameter from .table import TableParameter from .tableview import TableViewParameter, TableViewCustom from .itemselect import ItemSelectParameter @@ -13,29 +17,34 @@ from .text import PlainTextPbParameter from .numeric import NumericParameter from .group import GroupParameter -from pyqtgraph.parametertree.Parameter import registerParameterType, registerParameterItemType, Parameter +from .text_pattern import PatternParameter +from pyqtgraph.parametertree.Parameter import ( + registerParameterType, + registerParameterItemType, + Parameter, +) -registerParameterType('float', NumericParameter, override=True) -registerParameterType('int', NumericParameter, override=True) -registerParameterType('bool_push', BoolPushParameter, override=True) -registerParameterType('pixmap', PixmapParameter, override=True) -registerParameterType('pixmap_check', PixmapCheckParameter, override=True) +registerParameterType("float", NumericParameter, override=True) +registerParameterType("int", NumericParameter, override=True) +registerParameterType("bool_push", BoolPushParameter, override=True) +registerParameterType("pixmap", PixmapParameter, override=True) +registerParameterType("pixmap_check", PixmapCheckParameter, override=True) -registerParameterType('slide', SliderParameter, override=True) +registerParameterType("slide", SliderParameter, override=True) -registerParameterType('led', LedParameter, override=True) -registerParameterType('led_push', LedPushParameter, override=True) -registerParameterType('date', DateParameter, override=True) -registerParameterType('date_time', DateTimeParameter, override=True) -registerParameterType('time', TimeParameter, override=True) +registerParameterType("led", LedParameter, override=True) +registerParameterType("led_push", LedPushParameter, override=True) +registerParameterType("date", DateParameter, override=True) +registerParameterType("date_time", DateTimeParameter, override=True) +registerParameterType("time", TimeParameter, override=True) -registerParameterType('list', ListParameter, override=True) -registerParameterType('table', TableParameter, override=True) +registerParameterType("list", ListParameter, override=True) +registerParameterType("table_dict", DoubleColumnTableParameter, override=True) +registerParameterType("table", TableParameter, override=True) -registerParameterType('table_view', TableViewParameter, override=True) -registerParameterType('itemselect', ItemSelectParameter, override=True) -registerParameterType('browsepath', FileDirParameter, override=True) -registerParameterType('text_pb', PlainTextPbParameter, override=True) - -registerParameterType('text_pb', PlainTextPbParameter, override=True) -registerParameterType('group', GroupParameter, override=True) +registerParameterType("table_view", TableViewParameter, override=True) +registerParameterType("itemselect", ItemSelectParameter, override=True) +registerParameterType("browsepath", FileDirParameter, override=True) +registerParameterType("text_pb", PlainTextPbParameter, override=True) +registerParameterType("text_pattern", PatternParameter) +registerParameterType("group", GroupParameter, override=True) \ No newline at end of file diff --git a/src/pymodaq_gui/parameter/pymodaq_ptypes/table.py b/src/pymodaq_gui/parameter/pymodaq_ptypes/table.py index 08d664b4..29873923 100644 --- a/src/pymodaq_gui/parameter/pymodaq_ptypes/table.py +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/table.py @@ -1,135 +1,157 @@ -from qtpy import QtWidgets, QtCore -from collections import OrderedDict -from pyqtgraph.parametertree.parameterTypes.basetypes import WidgetParameterItem -from pyqtgraph.parametertree import Parameter +""" +Table parameter wrapping ManagedTableWidget. +""" +from pyqtgraph.parametertree.parameterTypes import WidgetParameterItem, SimpleParameter +from pymodaq_gui.utils.widgets.managed_table import ManagedTableWidget -class TableWidget(QtWidgets.QTableWidget): - """ - ============== =========================== - *Attributes** **Type** - *valuechanged* instance of pyqt Signal - *QtWidgets* instance of QTableWidget - ============== =========================== - """ - - valuechanged = QtCore.Signal(OrderedDict) - - def __init__(self): - super().__init__() +class TableParameterItem(WidgetParameterItem): + """Widget item wrapping ManagedTableWidget.""" - def get_table_value(self): - """ - Get the contents of the self coursed table. + def __init__(self, param, depth): + super().__init__(param, depth) + self.hideWidget = False - Returns - ------- - data : ordered dictionnary - The getted values dictionnary. - """ - data = OrderedDict([]) - for ind in range(self.rowCount()): - item0 = self.item(ind, 0) - item1 = self.item(ind, 1) - if item0 is not None and item1 is not None: - try: - data[item0.text()] = float(item1.text()) - except Exception: - data[item0.text()] = item1.text() - return data - - def set_table_value(self, data_dict): - """ - Set the data values dictionnary to the custom table. + def makeWidget(self): + """Create ManagedTableWidget.""" + opts = self.param.opts + self.asSubItem = True - =============== ====================== ================================================ - **Parameters** **Type** **Description** - *data_dict* ordered dictionnary the contents to be stored in the custom table - =============== ====================== ================================================ - """ + # Get initial value/data + initial_value = self.param.value() + + widget = ManagedTableWidget( + data=initial_value, + columns=opts.get("columns", None), # Let widget infer if not provided + rows=opts.get("rows", None), + enable_row_controls=opts.get("enable_row_controls", True), + max_display_rows=opts.get("max_display_rows", None), + delegate=opts.get("delegate")() if "delegate" in opts else None, + ) + # widget.setFocusPolicy(Qt.FocusPolicy.NoFocus) + # widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) + widget.setStyleSheet(""" + ManagedTableWidget { + background: transparent; + border: none; + } + """) + # Connect signals + widget.valueChanged.connect(self.widgetValueChanged) + widget.sigChanged = widget.valueChanged + + self.widget = widget + return widget + + def widgetValueChanged(self, data): + """Handle widget value changes.""" try: - self.setRowCount(len(data_dict)) - self.setColumnCount(2) - for ind, (key, value) in enumerate(data_dict.items()): - item0 = QtWidgets.QTableWidgetItem(key) - item0.setFlags(item0.flags() ^ QtCore.Qt.ItemIsEditable) - if isinstance(value, float): - item1 = QtWidgets.QTableWidgetItem('{:.3e}'.format(value)) - else: - item1 = QtWidgets.QTableWidgetItem(str(value)) - item1.setFlags(item1.flags() ^ QtCore.Qt.ItemIsEditable) - self.setItem(ind, 0, item0) - self.setItem(ind, 1, item1) - # self.valuechanged.emit(data_dict) - + self.param.setValue(data) except Exception as e: - pass + print(f"Error updating parameter: {e}") + def setValue(self, val): + """Set widget value.""" + self.widget.setValue(val) -class TableParameterItem(WidgetParameterItem): + def value(self): + """Get widget value.""" + return self.widget.value() - # def treeWidgetChanged(self): - # """ - # Check for changement in the Widget tree. - # """ - # # # TODO: fix so that superclass method can be called - # # # (WidgetParameter should just natively support this style) - # # WidgetParameterItem.treeWidgetChanged(self) - # self.treeWidget().setFirstItemColumnSpanned(self.subItem, True) - # self.treeWidget().setItemWidget(self.subItem, 0, self.widget) - # - # # for now, these are copied from ParameterItem.treeWidgetChanged - # self.setHidden(not self.param.opts.get('visible', True)) - # self.setExpanded(self.param.opts.get('expanded', True)) + def optsChanged(self, param, opts): + """Handle option changes.""" + super().optsChanged(param, opts) - def makeWidget(self): - """ - Make and initialize an instance of TableWidget. + if "value" in opts: + self.widget.setValue(opts["value"]) + self.widget.table.resizeColumnsToContents() - Returns - ------- - table : instance of TableWidget. - The initialized table. + if "delegate" in opts: + delegate = opts["delegate"]() + self.widget.setDelegate(delegate) - See Also - -------- - TableWidget - """ - self.asSubItem = True - self.hideWidget = False - opts = self.param.opts - w = TableWidget() - if 'tip' in opts: - w.setToolTip(opts['tip']) - w.setColumnCount(2) - if 'header' in opts: - w.setHorizontalHeaderLabels(self.param.opts['header']) - if 'height' not in opts: - opts['height'] = 200 - w.setMaximumHeight(opts['height']) - w.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) - # self.table.setReadOnly(self.param.opts.get('readonly', False)) - w.value = w.get_table_value - w.setValue = w.set_table_value - w.sigChanged = w.itemChanged - return w - - -class TableParameter(Parameter): - """ - =============== ================================= - **Attributes** **Type** - *itemClass* instance of TableParameterItem - *Parameter* instance of pyqtgraph parameter - =============== ================================= + +class TableParameter(SimpleParameter): + """Table parameter with row management and validation. + + Can be initialized with: + - data: Infers shape from data + - columns + rows: Empty table with specified shape + - value: Legacy support (same as data) """ - itemClass = TableParameterItem - """Editable string; displayed as large text box in the tree.""" - # def __init(self): - # super(TableParameter,self).__init__() + itemClass = TableParameterItem - def setValue(self, value): - self.opts['value'] = value - self.sigValueChanged.emit(self, value) + def __init__(self, **opts): + # Priority: data > value > columns/rows + if "data" in opts: + opts["value"] = opts.pop("data") + + if "value" not in opts: + rows = opts.get("rows", 5) + columns = opts.get("columns", ["Column 1", "Column 2", "Column 3"]) + opts["value"] = [[""] * len(columns) for _ in range(rows)] + + opts["expanded"] = True + super().__init__(**opts) + + def valueIsDefault(self): + return True + + def hasDefault(self): + return False + + def setOpts(self, **opts): + """Override to trigger optsChanged.""" + super().setOpts(**opts) + for item in self.items: + if hasattr(item, "optsChanged"): + item.optsChanged(self, opts) + + def setValue(self, value, blockSignal=None): + """Override to update widget.""" + super().setValue(value, blockSignal=blockSignal) + for item in self.items: + if hasattr(item, "setValue"): + item.setValue(value) + + def addRow(self, row_data=None): + """Add row programmatically.""" + current_value = self.value() + if row_data is None: + columns = self.opts.get("columns", ["Column 1", "Column 2", "Column 3"]) + row_data = [""] * len(columns) + new_value = current_value + [row_data] + self.setValue(new_value) + + def removeRow(self, row_index): + """Remove row by index.""" + current_value = self.value() + if 0 <= row_index < len(current_value): + new_value = current_value[:row_index] + current_value[row_index + 1 :] + self.setValue(new_value) + + def clearRows(self): + """Clear all row data.""" + current_value = self.value() + new_value = [[""] * len(row) for row in current_value] + self.setValue(new_value) + + def valueAsDict(self): + """Get value as dict with column names as keys. + + Returns: + dict: {column_name: [column_values]} + """ + columns = self.opts.get("columns", ["Column 1", "Column 2", "Column 3"]) + data = self.value() + + result = {col: [] for col in columns} + for row in data: + for col_idx, col_name in enumerate(columns): + if col_idx < len(row): + result[col_name].append(row[col_idx]) + else: + result[col_name].append("") + return result diff --git a/src/pymodaq_gui/parameter/pymodaq_ptypes/table_dict.py b/src/pymodaq_gui/parameter/pymodaq_ptypes/table_dict.py new file mode 100644 index 00000000..8bc54ee8 --- /dev/null +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/table_dict.py @@ -0,0 +1,134 @@ +from qtpy import QtWidgets, QtCore +from collections import OrderedDict +from pyqtgraph.parametertree.parameterTypes.basetypes import WidgetParameterItem +from pyqtgraph.parametertree import Parameter + + +class TableWidget(QtWidgets.QTableWidget): + """ + ============== =========================== + *Attributes** **Type** + *valuechanged* instance of pyqt Signal + *QtWidgets* instance of QTableWidget + ============== =========================== + """ + + valuechanged = QtCore.Signal(OrderedDict) + + def __init__(self): + super().__init__() + + def get_table_value(self): + """ + Get the contents of the self coursed table. + + Returns + ------- + data : ordered dictionnary + The getted values dictionnary. + """ + data = OrderedDict([]) + for ind in range(self.rowCount()): + item0 = self.item(ind, 0) + item1 = self.item(ind, 1) + if item0 is not None and item1 is not None: + try: + data[item0.text()] = float(item1.text()) + except Exception: + data[item0.text()] = item1.text() + return data + + def set_table_value(self, data_dict): + """ + Set the data values dictionnary to the custom table. + + =============== ====================== ================================================ + **Parameters** **Type** **Description** + *data_dict* ordered dictionnary the contents to be stored in the custom table + =============== ====================== ================================================ + """ + try: + self.setRowCount(len(data_dict)) + self.setColumnCount(2) + for ind, (key, value) in enumerate(data_dict.items()): + item0 = QtWidgets.QTableWidgetItem(key) + item0.setFlags(item0.flags() ^ QtCore.Qt.ItemIsEditable) + if isinstance(value, float): + item1 = QtWidgets.QTableWidgetItem("{:.3e}".format(value)) + else: + item1 = QtWidgets.QTableWidgetItem(str(value)) + item1.setFlags(item1.flags() ^ QtCore.Qt.ItemIsEditable) + self.setItem(ind, 0, item0) + self.setItem(ind, 1, item1) + # self.valuechanged.emit(data_dict) + + except Exception as e: + pass + + +class TableParameterItem(WidgetParameterItem): + # def treeWidgetChanged(self): + # """ + # Check for changement in the Widget tree. + # """ + # # # TODO: fix so that superclass method can be called + # # # (WidgetParameter should just natively support this style) + # # WidgetParameterItem.treeWidgetChanged(self) + # self.treeWidget().setFirstItemColumnSpanned(self.subItem, True) + # self.treeWidget().setItemWidget(self.subItem, 0, self.widget) + # + # # for now, these are copied from ParameterItem.treeWidgetChanged + # self.setHidden(not self.param.opts.get('visible', True)) + # self.setExpanded(self.param.opts.get('expanded', True)) + + def makeWidget(self): + """ + Make and initialize an instance of TableWidget. + + Returns + ------- + table : instance of TableWidget. + The initialized table. + + See Also + -------- + TableWidget + """ + self.asSubItem = True + self.hideWidget = False + opts = self.param.opts + w = TableWidget() + if "tip" in opts: + w.setToolTip(opts["tip"]) + w.setColumnCount(2) + if "header" in opts: + w.setHorizontalHeaderLabels(self.param.opts["header"]) + if "height" not in opts: + opts["height"] = 200 + w.setMaximumHeight(opts["height"]) + w.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + # self.table.setReadOnly(self.param.opts.get('readonly', False)) + w.value = w.get_table_value + w.setValue = w.set_table_value + w.sigChanged = w.itemChanged + return w + + +class DoubleColumnTableParameter(Parameter): + """ + =============== ================================= + **Attributes** **Type** + *itemClass* instance of TableParameterItem + *Parameter* instance of pyqtgraph parameter + =============== ================================= + """ + + itemClass = TableParameterItem + """Editable string; displayed as large text box in the tree.""" + + # def __init(self): + # super(TableParameter,self).__init__() + + def setValue(self, value): + self.opts["value"] = value + self.sigValueChanged.emit(self, value) diff --git a/src/pymodaq_gui/parameter/pymodaq_ptypes/text_pattern.py b/src/pymodaq_gui/parameter/pymodaq_ptypes/text_pattern.py new file mode 100644 index 00000000..5066a374 --- /dev/null +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/text_pattern.py @@ -0,0 +1,477 @@ +""" +Pattern Completer Parameter Item for pyqtgraph + +This module provides a custom parameter item that integrates pattern completion +functionality into pyqtgraph's parameter tree system. + +Features: +- Multiple trigger patterns (e.g., '@' for users, '#' for tags) +- Dynamic updates via convenience methods or direct setOpts +- Configurable completion behavior (case sensitivity, popup size, etc.) +- Full integration with pyqtgraph's parameter system +""" + +from pyqtgraph.parametertree import Parameter +from pyqtgraph.parametertree.parameterTypes import TextParameterItem, SimpleParameter +from pymodaq_gui.utils.widgets.pattern_completer import PatternPlainTextEdit + + +class PatternParameterItem(TextParameterItem): + """ + A parameter item that provides pattern completion in the text editor. + + This extends pyqtgraph's TextParameterItem to add pattern-based autocompletion. + You can configure multiple completion patterns (e.g., '@' for mentions, '#' for tags). + """ + + def __init__(self, param, depth): + super().__init__(param, depth) + + def makeWidget(self): + """Create the pattern-enabled line edit widget""" + self.hideWidget = False + self.asSubItem = True + completer_config = self.param.opts.get("completer_config", {}) + + # Create the widget with global configuration + widget = PatternPlainTextEdit(**completer_config) + + # Add pattern completers + patterns = self.param.opts.get("patterns", {}) + for pattern, completions in patterns.items(): + # Per-pattern config can override global config + pattern_config = self.param.opts.get(f"pattern_config_{pattern}", {}) + widget.add_completer(pattern, completions, **pattern_config) + + widget.value = widget.toPlainText + widget.setValue = widget.setPlainText + widget.sigChanged = widget.textChanged + + self.widget = widget + + return widget + + def optsChanged(self, param, opts): + """Handle parameter option changes - this is triggered by setOpts""" + super().optsChanged(param, opts) + + # Update patterns if changed + if "patterns" in opts and hasattr(self, "widget") and self.widget is not None: + patterns = opts["patterns"] + + # First, handle removed patterns (in widget but not in new opts) + if hasattr(self.widget, "completers"): + widget_patterns = set(self.widget.completers.keys()) + opts_patterns = set(patterns.keys()) + removed_patterns = widget_patterns - opts_patterns + + for pattern in removed_patterns: + del self.widget.completers[pattern] + + # Then, add new patterns or update existing ones + for pattern, completions in patterns.items(): + # Check if pattern exists in widget's completers + if ( + hasattr(self.widget, "completers") + and pattern in self.widget.completers + ): + # Pattern exists, just update completions + self.widget.update_completions(pattern, completions) + else: + # New pattern, add it + pattern_config = self.param.opts.get( + f"pattern_config_{pattern}", {} + ) + self.widget.add_completer(pattern, completions, **pattern_config) + + # Update completer config if changed + if ( + "completer_config" in opts + and hasattr(self, "widget") + and self.widget is not None + ): + self.widget.set_global_config(**opts["completer_config"]) + + +class PatternParameter(SimpleParameter): + """ + Parameter class for pattern completion. + + This parameter type allows text input with pattern-based autocompletion. + Uses pyqtgraph's setOpts for all updates. + + Users can update patterns in two ways: + 1. Convenience methods: update_completions(), add_pattern(), remove_pattern() + 2. Direct setOpts: parameter.setOpts(patterns=new_patterns_dict) + """ + + itemClass = PatternParameterItem + + def __init__(self, **opts): + # Set default options + opts.setdefault("patterns", {}) + opts.setdefault("completer_config", {}) + super().__init__(**opts) + + def add_pattern(self, pattern, completions, **config): + """ + Add or update a completion pattern after initialization. + + Args: + pattern (str): Trigger string (e.g., '@', '#') + completions (list): List of completion strings + **config: Pattern-specific configuration + """ + # Create new patterns dict with the added/updated pattern + new_patterns = dict(self.opts.get("patterns", {})) + new_patterns[pattern] = list( + completions + ) # Use list() to create a new list object + + # Build opts dict for setOpts + opts_to_set = {"patterns": new_patterns} + + # Add pattern-specific config if provided + if config: + opts_to_set[f"pattern_config_{pattern}"] = config + + # Let setOpts handle everything + self.setOpts(**opts_to_set) + + def update_completions(self, pattern, completions): + """ + Update the completion list for a specific pattern. + + Args: + pattern (str): The pattern to update + completions (list): New list of completion strings + """ + if pattern not in self.opts.get("patterns", {}): + print( + f"Warning: Pattern '{pattern}' not found. Use add_pattern() to add it first." + ) + return + + # Create new patterns dict with the updated pattern + new_patterns = dict(self.opts.get("patterns", {})) + new_patterns[pattern] = list( + completions + ) # Use list() to create a new list object + + # Let setOpts handle everything + self.setOpts(patterns=new_patterns) + + def remove_pattern(self, pattern): + """ + Remove a completion pattern. + + Args: + pattern (str): The pattern to remove + """ + if pattern not in self.opts.get("patterns", {}): + return + + # Create new patterns dict without the removed pattern + new_patterns = dict(self.opts.get("patterns", {})) + del new_patterns[pattern] + + # Let setOpts handle everything (optsChanged will handle widget cleanup) + self.setOpts(patterns=new_patterns) + + def set_completer_config(self, **config): + """ + Update global completer configuration. + + Args: + **config: Configuration options (min_width, max_width, etc.) + """ + # Create new config dict with updates + new_config = dict(self.opts.get("completer_config", {})) + new_config.update(config) + + # Let setOpts handle everything + self.setOpts(completer_config=new_config) + + +# Example usage and demo +if __name__ == "__main__": + import sys + from qtpy.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + QGroupBox, + ) + from pyqtgraph.parametertree import ParameterTree + + app = QApplication(sys.argv) + + # Create a parameter tree with pattern parameters + params = [ + { + "name": "Text Editing", + "type": "group", + "children": [ + { + "name": "Message", + "type": "text_pattern", + "value": "", + "patterns": { + "@": ["alice", "bob", "charlie"], + "#": ["python", "javascript", "cpp"], + }, + "completer_config": { + "min_width": 200, + "max_width": 400, + "case_sensitive": False, + "visual_indicator": True, + }, + }, + ], + }, + ] + + # Create parameter tree + p = Parameter.create(name="params", type="group", children=params) + tree = ParameterTree() + tree.setParameters(p, showTop=False) + + # Get the Message parameter for examples + message_param = p.child("Text Editing").child("Message") + + # ======================================================================== + # EXAMPLE 1: Using convenience methods + # ======================================================================== + def example_convenience_update(): + """Update completions using the convenience method""" + print("\n" + "=" * 60) + print("EXAMPLE 1: Update using convenience method") + print("=" * 60) + new_completions = ["alice", "bob", "charlie", "david", "eve"] + message_param.update_completions("@", new_completions) + print(f"✓ Updated @ completions: {new_completions}") + print("=" * 60 + "\n") + + def example_convenience_add(): + """Add pattern using the convenience method""" + print("\n" + "=" * 60) + print("EXAMPLE 2: Add pattern using convenience method") + print("=" * 60) + message_param.add_pattern("$", ["dollar", "euro", "pound", "yen"]) + print(f"✓ Added $ pattern with currency completions") + print("=" * 60 + "\n") + + def example_convenience_remove(): + """Remove pattern using the convenience method""" + print("\n" + "=" * 60) + print("EXAMPLE 3: Remove pattern using convenience method") + print("=" * 60) + message_param.remove_pattern("#") + print(f"✓ Removed # pattern") + print("=" * 60 + "\n") + + # ======================================================================== + # EXAMPLE 2: Using direct setOpts (standard pyqtgraph approach) + # ======================================================================== + def example_setopts_update(): + """Update completions using setOpts directly""" + print("\n" + "=" * 60) + print("EXAMPLE 4: Update using setOpts directly") + print("=" * 60) + + # Get current patterns + current_patterns = message_param.opts.get("patterns", {}) + + # Create new patterns dict with updated completions + new_patterns = dict(current_patterns) + new_patterns["@"] = ["alice", "bob", "charlie", "frank", "grace"] + + # Apply changes + message_param.setOpts(patterns=new_patterns) + print(f"✓ Updated @ completions using setOpts") + print(f" Code: message_param.setOpts(patterns=new_patterns)") + print("=" * 60 + "\n") + + def example_setopts_add(): + """Add pattern using setOpts directly""" + print("\n" + "=" * 60) + print("EXAMPLE 5: Add pattern using setOpts directly") + print("=" * 60) + + # Get current patterns and add new one + current_patterns = message_param.opts.get("patterns", {}) + new_patterns = dict(current_patterns) + new_patterns[":"] = ["smile", "heart", "fire", "star"] + + # Apply changes + message_param.setOpts(patterns=new_patterns) + print(f"✓ Added : pattern using setOpts") + print(f" Code: message_param.setOpts(patterns=new_patterns)") + print("=" * 60 + "\n") + + def example_setopts_remove(): + """Remove pattern using setOpts directly""" + print("\n" + "=" * 60) + print("EXAMPLE 6: Remove pattern using setOpts directly") + print("=" * 60) + + # Get current patterns and remove one + current_patterns = message_param.opts.get("patterns", {}) + new_patterns = dict(current_patterns) + if "$" in new_patterns: + del new_patterns["$"] + + # Apply changes + message_param.setOpts(patterns=new_patterns) + print(f"✓ Removed $ pattern using setOpts") + print(f" Code: message_param.setOpts(patterns=new_patterns)") + print("=" * 60 + "\n") + + def example_setopts_config(): + """Update config using setOpts directly""" + print("\n" + "=" * 60) + print("EXAMPLE 7: Update config using setOpts directly") + print("=" * 60) + + # Get current config and update it + current_config = message_param.opts.get("completer_config", {}) + new_config = dict(current_config) + new_config["min_width"] = 300 + new_config["max_width"] = 600 + + # Apply changes + message_param.setOpts(completer_config=new_config) + print(f"✓ Updated completer config using setOpts") + print(f" Code: message_param.setOpts(completer_config=new_config)") + print("=" * 60 + "\n") + + # ======================================================================== + # EXAMPLE 3: Programmatic/automated updates + # ======================================================================== + def example_auto_update(): + """Example of automated updates (e.g., from a timer or callback)""" + print("\n" + "=" * 60) + print("EXAMPLE 8: Automated update simulation") + print("=" * 60) + + # Simulate loading user list from a database + users_from_db = [ + "alice", + "bob", + "charlie", + "david", + "eve", + "frank", + "grace", + "henry", + ] + + current_patterns = message_param.opts.get("patterns", {}) + new_patterns = dict(current_patterns) + new_patterns["@"] = users_from_db + message_param.setOpts(patterns=new_patterns) + + print(f"✓ Updated @ with {len(users_from_db)} users from 'database'") + print("=" * 60 + "\n") + + # Create window + window = QWidget() + main_layout = QVBoxLayout() + + # Add instructions + instructions = QLabel( + "Pattern Completer Parameter - Interactive Demo

" + "How to use:
" + "• Type '@' in Message field to see user completions
" + "• Type '#' to see tag completions
" + "• Click buttons below to dynamically update patterns

" + "Two approaches available:
" + "1. Convenience methods: update_completions(), add_pattern(), remove_pattern()
" + "2. Direct setOpts(): Standard pyqtgraph approach - message_param.setOpts(patterns=...)" + ) + instructions.setWordWrap(True) + main_layout.addWidget(instructions) + + # Add parameter tree + main_layout.addWidget(tree) + + # Group 1: Convenience Methods + group1 = QGroupBox("Method 1: Convenience Methods") + group1_layout = QVBoxLayout() + + btn1_1 = QPushButton("Update @ completions (add david, eve)") + btn1_1.clicked.connect(example_convenience_update) + group1_layout.addWidget(btn1_1) + + btn1_2 = QPushButton("Add $ pattern (currencies)") + btn1_2.clicked.connect(example_convenience_add) + group1_layout.addWidget(btn1_2) + + btn1_3 = QPushButton("Remove # pattern") + btn1_3.clicked.connect(example_convenience_remove) + group1_layout.addWidget(btn1_3) + + group1.setLayout(group1_layout) + main_layout.addWidget(group1) + + # Group 2: Direct setOpts + group2 = QGroupBox("Method 2: Direct setOpts (Standard PyQtGraph)") + group2_layout = QVBoxLayout() + + btn2_1 = QPushButton("Update @ with setOpts (add frank, grace)") + btn2_1.clicked.connect(example_setopts_update) + group2_layout.addWidget(btn2_1) + + btn2_2 = QPushButton("Add : pattern with setOpts (emojis)") + btn2_2.clicked.connect(example_setopts_add) + group2_layout.addWidget(btn2_2) + + btn2_3 = QPushButton("Remove $ with setOpts") + btn2_3.clicked.connect(example_setopts_remove) + group2_layout.addWidget(btn2_3) + + btn2_4 = QPushButton("Update config with setOpts (wider popup)") + btn2_4.clicked.connect(example_setopts_config) + group2_layout.addWidget(btn2_4) + + group2.setLayout(group2_layout) + main_layout.addWidget(group2) + + # Group 3: Advanced + group3 = QGroupBox("Advanced Examples") + group3_layout = QVBoxLayout() + + btn3_1 = QPushButton("Simulate loading users from database") + btn3_1.clicked.connect(example_auto_update) + group3_layout.addWidget(btn3_1) + + group3.setLayout(group3_layout) + main_layout.addWidget(group3) + + window.setLayout(main_layout) + window.setWindowTitle("Pattern Completer Parameter - Demo") + window.resize(800, 700) + window.show() + + # Print initial state + print("\n" + "=" * 70) + print("PATTERN COMPLETER PARAMETER - INTERACTIVE DEMO") + print("=" * 70) + print("\nInitial state:") + print(f" Message patterns: {list(message_param.opts.get('patterns', {}).keys())}") + print(f" @ completions: {message_param.opts['patterns']['@']}") + print(f" # completions: {message_param.opts['patterns']['#']}") + print("\nTry typing '@' or '#' in the Message field to see completions!") + print("Then click buttons to see dynamic updates in action.") + print("=" * 70 + "\n") + + # Monitor value changes + def value_changed(param, value): + if value: # Only print non-empty values + print(f"[Value Changed] {param.name()}: {value}") + + p.sigTreeStateChanged.connect(value_changed) + + sys.exit(app.exec_()) diff --git a/src/pymodaq_gui/utils/widgets/delegates.py b/src/pymodaq_gui/utils/widgets/delegates.py new file mode 100644 index 00000000..78b57a42 --- /dev/null +++ b/src/pymodaq_gui/utils/widgets/delegates.py @@ -0,0 +1,329 @@ +""" +Custom delegates for widget editing. +""" + +from qtpy.QtWidgets import ( + QStyledItemDelegate, + QSpinBox, + QDoubleSpinBox, + QComboBox, + QLineEdit, + QCheckBox, + QStyle, +) +from qtpy.QtCore import Qt, QEvent +from pymodaq_gui.utils.widgets.pattern_completer import PatternLineEdit +from pyqtgraph.widgets.SpinBox import SpinBox +from pymodaq_data import Q_ + +class NumericDelegate(QStyledItemDelegate): + """Delegate for numeric input with spinbox.""" + + def __init__(self, min_val=0, max_val=100, decimals=2): + super().__init__() + self.min_val = min_val + self.max_val = max_val + self.decimals = decimals + + def createEditor(self, parent, option, index): + if self.decimals > 0: + editor = QDoubleSpinBox(parent) + editor.setDecimals(self.decimals) + else: + editor = QSpinBox(parent) + + editor.setMinimum(self.min_val) + editor.setMaximum(self.max_val) + return editor + + def setEditorData(self, editor, index): + value = index.model().data(index, Qt.EditRole) + try: + editor.setValue(float(value) if value else 0) + except (ValueError, TypeError): + editor.setValue(0) + + def setModelData(self, editor, model, index): + editor.interpretText() + value = editor.value() + model.setData(index, str(value), Qt.EditRole) + + +class ComboBoxDelegate(QStyledItemDelegate): + """Delegate for dropdown selection.""" + + def __init__(self, items): + super().__init__() + self.items = items + + def createEditor(self, parent, option, index): + editor = QComboBox(parent) + editor.addItems(self.items) + # Auto-commit on selection change + editor.currentIndexChanged.connect(lambda: self._commit_and_close(editor)) + return editor + + def setEditorData(self, editor, index): + value = index.model().data(index, Qt.EditRole) + # Block signals during initial setup to avoid premature commit + editor.blockSignals(True) + idx = editor.findText(value) + if idx >= 0: + editor.setCurrentIndex(idx) + editor.blockSignals(False) + # Show popup immediately + editor.showPopup() + + def setModelData(self, editor, model, index): + value = editor.currentText() + model.setData(index, value, Qt.EditRole) + + def _commit_and_close(self, editor): + """Commit and close editor when selection changes.""" + self.commitData.emit(editor) + self.closeEditor.emit(editor) + + +class ColumnSpecificDelegate(QStyledItemDelegate): + """Delegate that applies different delegates to different columns.""" + + def __init__(self, column_delegates): + """ + Parameters + ---------- + column_delegates : dict + Dictionary mapping column indices to delegate instances. + Example: {0: NumericDelegate(), 1: ComboBoxDelegate(['A', 'B'])} + """ + super().__init__() + self.column_delegates = column_delegates + + def createEditor(self, parent, option, index): + col = index.column() + if col in self.column_delegates: + return self.column_delegates[col].createEditor(parent, option, index) + return super().createEditor(parent, option, index) + + def setEditorData(self, editor, index): + col = index.column() + if col in self.column_delegates: + self.column_delegates[col].setEditorData(editor, index) + else: + super().setEditorData(editor, index) + + def setModelData(self, editor, model, index): + col = index.column() + if col in self.column_delegates: + self.column_delegates[col].setModelData(editor, model, index) + else: + super().setModelData(editor, model, index) + + +class ReadOnlyDelegate(QStyledItemDelegate): + """Delegate that makes cells read-only.""" + + def createEditor(self, parent, option, index): + return None + + +class ColumnReadOnlyDelegate(QStyledItemDelegate): + """Delegate that makes specific columns read-only.""" + + def __init__(self, readonly_columns): + """ + Parameters + ---------- + readonly_columns : list + List of column indices that should be read-only. + """ + super().__init__() + self.readonly_columns = set(readonly_columns) + + def createEditor(self, parent, option, index): + if index.column() in self.readonly_columns: + return None + return super().createEditor(parent, option, index) + + +class PatternCompleterDelegate(QStyledItemDelegate): + """ + Custom delegate for QTableWidget that uses PatternLineEdit with mixin. + + Usage: + delegate = PatternCompleterDelegate(min_width=200, max_width=600) + delegate.add_completer('@', ['USA', 'Canada', 'Mexico']) + delegate.add_completer('#', ['Python', 'Java', 'C++'], case_sensitive=True) + table.setItemDelegateForColumn(0, delegate) + """ + + def __init__(self, parent=None, **kwargs): + """ + Initialize delegate with global configuration. + + Args: + **kwargs: Global configuration options (same as init_pattern_completer) + """ + super().__init__(parent) + self.completer_configs = {} # pattern -> config dict + self.global_kwargs = kwargs + + def add_completer(self, pattern, completions, **kwargs): + """ + Add a completer pattern for this delegate. + + Args: + pattern: Trigger string (e.g., '@', '#') + completions: List of completion strings + **kwargs: Pattern-specific configuration (overrides global) + """ + self.completer_configs[pattern] = { + "completions": completions, + "kwargs": kwargs, + } + + def update_completions(self, pattern, completions): + """Update the completion list for a specific pattern""" + if pattern in self.completer_configs: + self.completer_configs[pattern]["completions"] = completions + + def update_completer_config(self, pattern, **kwargs): + """Update configuration for a specific pattern""" + if pattern in self.completer_configs: + self.completer_configs[pattern]["kwargs"].update(kwargs) + + def set_global_config(self, **kwargs): + """Update global configuration""" + self.global_kwargs.update(kwargs) + + def createEditor(self, parent, option, index): + """Create a PatternLineEdit when editing starts""" + try: + editor = PatternLineEdit(parent, **self.global_kwargs) + + # Add all configured completers + for pattern, config in self.completer_configs.items(): + editor.add_completer( + pattern, config["completions"], **config.get("kwargs", {}) + ) + + return editor + except Exception as e: + print(f"Error creating editor: {e}") + # Fallback to basic QLineEdit + return QLineEdit(parent) + + def setEditorData(self, editor: PatternLineEdit, index): + """Load data from model into editor""" + try: + if not editor or not index.isValid(): + return + value = index.model().data(index, Qt.ItemDataRole.DisplayRole) + if value is not None: + editor.setText(str(value)) + else: + editor.clear() + except Exception as e: + print(f"Error setting editor data: {e}") + pass + + def setModelData(self, editor: PatternLineEdit, model, index): + """Save data from editor back to model""" + try: + if not editor or not model or not index.isValid(): + return + text = editor.text() + model.setData(index, text, Qt.ItemDataRole.EditRole) + except Exception as e: + print(f"Error setting model data: {e}") + pass + + def destroyEditor(self, editor: PatternLineEdit, index): + """Clean up editor when done""" + try: + if editor and hasattr(editor, "cleanup_pattern_completer"): + editor.cleanup_pattern_completer() + except Exception as e: + print(f"Error destroying editor: {e}") + pass + + try: + super().destroyEditor(editor, index) + except Exception as e: + print(f"Error in super destroyEditor: {e}") + pass + + +class SpinBoxDelegate(QStyledItemDelegate): + def __init__(self, parent=None, decimals=4, min=-1e6, max=1e6, units=None): + self.decimals = decimals + self.min = min + self.max = max + self.units = units + super().__init__(parent) + + def createEditor(self, parent, option, index): + doubleSpinBox = SpinBox(parent) + doubleSpinBox.setDecimals(self.decimals) + doubleSpinBox.setMaximum(self.min) + doubleSpinBox.setMaximum(self.max) + if self.units is not None: + doubleSpinBox.setSuffix(self.units) + return doubleSpinBox + + def setEditorData(self, editor: SpinBox, index): + data = index.data() if index.data() else 0 + editor.setValue(Q_(data).magnitude) + # editor.setSuffix(Q_(index.data()).units) + + def setModelData(self, editor: SpinBox, model, index): + model.setData( + index, + f"{editor.value()} {editor.opts['suffix']}" + if self.units is not None + else f"{editor.value()}", + Qt.ItemDataRole.EditRole, + ) + + +class BooleanDelegate(QStyledItemDelegate): + """ + TO implement custom widget editor for cells in a tableview + """ + + def __init__(self, check_symbol="True", cross_symbol="False", parent=None): + super().__init__(parent) + self.check_symbol = check_symbol + self.cross_symbol = cross_symbol + + def createEditor(self, parent, option, index): + boolean = QCheckBox(parent) + return boolean + + def setEditorData(self, editor, index): + value = str(index.data()).lower() in ("true", "1", "yes") + editor.setChecked(value) + + def setModelData(self, editor, model, index): + value = "True" if editor.isChecked() else "False" + model.setData(index, value, Qt.ItemDataRole.EditRole) + + def displayText(self, value, locale): + """Convert boolean to checkmark/cross.""" + is_true = str(value).lower() in ("true", "1", "yes") + return self.check_symbol if is_true else self.cross_symbol + + +class YesNoDelegate(BooleanDelegate): + """Boolean delegate showing 'Yes'/'No' (more readable than True/False).""" + + def __init__(self, parent=None): + super().__init__(check_symbol="Yes", cross_symbol="No", parent=parent) + + +class CheckmarkDelegate(BooleanDelegate): + """Boolean delegate showing ✓/✗ symbols (compact, visual).""" + + def __init__(self, parent=None): + super().__init__(check_symbol="✓", cross_symbol="✗",parent=parent) + + diff --git a/src/pymodaq_gui/utils/widgets/managed_table.py b/src/pymodaq_gui/utils/widgets/managed_table.py new file mode 100644 index 00000000..e50d01fe --- /dev/null +++ b/src/pymodaq_gui/utils/widgets/managed_table.py @@ -0,0 +1,285 @@ +""" +Standalone table widget with row management and validation. +Can be used independently or wrapped by TableParameter. +""" + +from qtpy.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QTableWidget, + QTableWidgetItem, + QHeaderView, + QPushButton, + QStyle, +) +from qtpy.QtCore import Signal + + +class ManagedTableWidget(QWidget): + """Reusable table widget with row controls and delegate support. + + Shape is inferred from data if provided, otherwise uses columns/rows parameters. + """ + + valueChanged = Signal(list) # Emits list of lists + + def __init__( + self, + data=None, + columns=None, + rows=None, + enable_row_controls=True, + max_display_rows=None, + delegate=None, + parent=None, + ): + super().__init__(parent) + + # Handle dict format: {column_name: [values]} + if isinstance(data, dict): + data, columns = self._dict_to_list(data, columns) + + # Infer shape from data if provided + if data is not None and len(data) > 0: + self._initial_data = data + if columns is None: + # Infer column count from first row + num_cols = len(data[0]) if data else 3 + columns = [f"Column {i + 1}" for i in range(num_cols)] + if rows is None: + rows = len(data) + else: + self._initial_data = None + if columns is None: + columns = ["Column 1", "Column 2", "Column 3"] + if rows is None: + rows = 5 + + self.columns = columns if isinstance(columns, list) else list(columns) + self._delegate = delegate + + self._setup_ui(rows, enable_row_controls, max_display_rows or rows) + + def _setup_ui(self, rows, enable_row_controls, max_display_rows): + """Setup the UI components.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Table + self.table = QTableWidget(rows, len(self.columns)) + self.table.setHorizontalHeaderLabels(self.columns) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Interactive + ) + self.table.setAlternatingRowColors(False) + + # Height constraint + row_height = self.table.verticalHeader().defaultSectionSize() + header_height = self.table.horizontalHeader().height() + max_height = header_height + (row_height * max_display_rows) + 10 + self.table.setMaximumHeight(max_height) + + # Delegate + if self._delegate: + self.table.setItemDelegate(self._delegate) + + # Signals + self.table.itemChanged.connect(self._on_item_changed) + + layout.addWidget(self.table) + + # Load initial data if provided + if self._initial_data is not None: + self.setValue(self._initial_data) + + # Row controls + if enable_row_controls: + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + + btn_add = QPushButton() + btn_add.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) + ) + btn_add.setToolTip("Add row") + btn_add.setMaximumWidth(30) + btn_add.clicked.connect(self.addRow) + + btn_remove = QPushButton() + btn_remove.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon) + ) + btn_remove.setToolTip("Remove selected row(s)") + btn_remove.setMaximumWidth(30) + btn_remove.clicked.connect(self.removeSelectedRows) + + btn_clear = QPushButton() + btn_clear.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton) + ) + btn_clear.setToolTip("Clear all") + btn_clear.setMaximumWidth(30) + btn_clear.clicked.connect(self.clearAll) + + button_layout.addWidget(btn_add) + button_layout.addWidget(btn_remove) + button_layout.addWidget(btn_clear) + button_layout.addStretch() + + layout.addLayout(button_layout) + + def _on_item_changed(self): + """Emit value changed signal.""" + self.valueChanged.emit(self.value()) + + @staticmethod + def _dict_to_list(data_dict, columns=None): + """Convert dict format to list of lists. + + Args: + data_dict: Dict with {column_name: [values]} format + columns: Optional column order. If None, uses dict keys order. + + Returns: + tuple: (list_of_lists, column_names) + """ + if not data_dict: + return [], [] + + # Determine column order + if columns is None: + columns = list(data_dict.keys()) + + # Verify all columns exist in dict + for col in columns: + if col not in data_dict: + raise ValueError(f"Column '{col}' not found in data dict") + + # Get max length to handle uneven columns + max_len = max(len(data_dict[col]) for col in columns) + + # Convert to list of lists + list_data = [] + for i in range(max_len): + row = [] + for col in columns: + col_data = data_dict[col] + row.append(str(col_data[i]) if i < len(col_data) else "") + list_data.append(row) + + return list_data, columns + + @staticmethod + def _list_to_dict(list_data, columns): + """Convert list of lists to dict format. + + Args: + list_data: List of lists + columns: Column names + + Returns: + dict: {column_name: [values]} format + """ + result = {col: [] for col in columns} + for row in list_data: + for col_idx, col_name in enumerate(columns): + if col_idx < len(row): + result[col_name].append(row[col_idx]) + else: + result[col_name].append("") + return result + + def setValue(self, data): + """Set table data from list of lists or dict.""" + if isinstance(data, dict): + data, _ = self._dict_to_list(data, self.columns) + + if not data: + return + + self.table.blockSignals(True) + + if len(data) != self.table.rowCount(): + self.table.setRowCount(len(data)) + + for row_idx, row_data in enumerate(data): + for col_idx, cell_value in enumerate(row_data): + if col_idx >= self.table.columnCount(): + break + item = QTableWidgetItem(str(cell_value)) + self.table.setItem(row_idx, col_idx, item) + + self.table.blockSignals(False) + + def value(self): + """Get table data as list of lists.""" + data = [] + for row in range(self.table.rowCount()): + row_data = [] + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + row_data.append(item.text() if item else "") + data.append(row_data) + return data + + def valueAsDict(self): + """Get table data as dict with column names as keys.""" + return self._list_to_dict(self.value(), self.columns) + + def addRow(self, row_data=None): + """Add a new row.""" + self.table.blockSignals(True) + row_count = self.table.rowCount() + self.table.insertRow(row_count) + + if not row_data: + row_data = [""] * self.table.columnCount() + + for col, value in enumerate(row_data): + if col < self.table.columnCount(): + self.table.setItem(row_count, col, QTableWidgetItem(str(value))) + + self.table.blockSignals(False) + self._on_item_changed() + + def removeSelectedRows(self): + """Remove selected rows.""" + selected_rows = set(item.row() for item in self.table.selectedItems()) + if not selected_rows: + return + + self.table.blockSignals(True) + for row in sorted(selected_rows, reverse=True): + self.table.removeRow(row) + self.table.blockSignals(False) + self._on_item_changed() + + def removeRow(self, row_index): + """Remove row by index.""" + if 0 <= row_index < self.table.rowCount(): + self.table.blockSignals(True) + self.table.removeRow(row_index) + self.table.blockSignals(False) + self._on_item_changed() + + def clearAll(self): + """Clear all cell data.""" + self.table.blockSignals(True) + for row in range(self.table.rowCount()): + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + if item: + item.setText("") + else: + self.table.setItem(row, col, QTableWidgetItem("")) + self.table.blockSignals(False) + self._on_item_changed() + + def setDelegate(self, delegate): + """Set item delegate for validation.""" + self._delegate = delegate + if delegate: + self.table.setItemDelegate(delegate) diff --git a/src/pymodaq_gui/utils/widgets/pattern_completer.py b/src/pymodaq_gui/utils/widgets/pattern_completer.py new file mode 100644 index 00000000..aa49fcbe --- /dev/null +++ b/src/pymodaq_gui/utils/widgets/pattern_completer.py @@ -0,0 +1,493 @@ +from qtpy.QtWidgets import ( + QWidget, + QCompleter, + QLineEdit, + QTextEdit, + QPlainTextEdit, + QListView, # For word wrap +) +from qtpy.QtCore import Qt, QRect +from qtpy.QtGui import QStandardItemModel, QStandardItem, QTextCursor, QFontMetrics + + +class PatternCompleter: + """ + Mixin class that adds pattern completion to any text widget. + + Requirements for the widget: + - Must have: text(), setText(), cursorPosition() or textCursor() + - Must emit: textChanged signal + - Must support: keyPressEvent override + + Usage: + class MyLineEdit(QLineEdit, PatternCompleterMixin): + def __init__(self, parent=None): + super().__init__(parent) + self.init_pattern_completer() + """ + + def init_pattern_completer(self, **kwargs): + """ + Initialize the pattern completer system. + + Args: + **kwargs: Global configuration options + - min_width (int): Minimum popup width in pixels (default: 150) + - max_width (int): Maximum popup width in pixels (default: 500) + - visual_indicator (bool): Enable visual indicator globally (default: False) + - case_sensitive (bool): Case sensitive completion (default: False) + - completion_mode (str): 'popup' or 'inline' (default: 'popup') + - auto_resize (bool): Auto-resize popup to content (default: True) + - word_wrap (bool): Enable word wrap in popup (default: False) + """ + # Initialize all attributes first + self: QWidget # Type hint for IDEs + self.completers = {} + self.active_pattern = None + self.trigger_start_pos = -1 + self.inserting_completion = False + self._is_destroyed = False + + # Global configuration with defaults + self.global_config = { + "min_width": kwargs.get("min_width", 150), + "max_width": kwargs.get("max_width", 500), + "visual_indicator": kwargs.get("visual_indicator", False), + "case_sensitive": kwargs.get("case_sensitive", False), + "completion_mode": kwargs.get("completion_mode", "popup"), + "auto_resize": kwargs.get("auto_resize", True), + "word_wrap": kwargs.get("word_wrap", False), + } + + # Connect to text changes + if hasattr(self, "textChanged"): + try: + self.textChanged.connect(self._pattern_on_text_changed) + except Exception as e: + print(f"Error connecting textChanged signal: {e}") + + def add_completer(self, pattern, completions, **kwargs): + """ + Add a completer for a specific trigger pattern. + + Args: + pattern (str): Trigger string (e.g., '@', '#', '::') + completions (list): List of completion strings + **kwargs: Per-pattern configuration (overrides global config) + - visual_indicator (bool): Show visual indicator for this pattern + - case_sensitive (bool): Case sensitive completion + - min_width (int): Minimum popup width + - max_width (int): Maximum popup width + - completion_mode (str): 'popup' or 'inline' + - auto_resize (bool): Auto-resize popup + - word_wrap (bool): Word wrap in popup + - padding (int): Extra padding for width calculation (default: 20) + """ + model = QStandardItemModel() + for item in completions: + model.appendRow(QStandardItem(item)) + + completer = QCompleter(model, self) + + # Apply configuration (pattern-specific overrides global) + config = {**self.global_config, **kwargs} + + case_sensitivity = ( + Qt.CaseSensitivity.CaseSensitive + if config.get("case_sensitive", False) + else Qt.CaseSensitivity.CaseInsensitive + ) + completer.setCaseSensitivity(case_sensitivity) + + completion_mode_str = config.get("completion_mode", "popup") + if completion_mode_str == "inline": + completer.setCompletionMode(QCompleter.CompletionMode.InlineCompletion) + else: + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + + completer.activated.connect(self._pattern_insert_completion) + + # Configure popup + popup = completer.popup() + min_width = config.get("min_width", 150) + max_width = config.get("max_width", 500) + popup.setMinimumWidth(min_width) + popup.setMaximumWidth(max_width) + + if isinstance(popup, QListView): + word_wrap = config.get("word_wrap", False) + popup.setWordWrap(word_wrap) + popup.setTextElideMode( + Qt.TextElideMode.ElideNone + if not word_wrap + else Qt.TextElideMode.ElideRight + ) + popup.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + popup.setResizeMode(QListView.ResizeMode.Adjust) + + self.completers[pattern] = { + "completer": completer, + "model": model, + "completions": completions, + "config": config, + } + + def update_completions(self, pattern, completions): + """Update completion list for a pattern""" + if pattern not in self.completers: + return + + config = self.completers[pattern] + config["completions"] = completions + config["model"].clear() + for item in completions: + config["model"].appendRow(QStandardItem(item)) + + def update_completer_config(self, pattern, **kwargs): + """ + Update configuration for a specific pattern completer. + + Args: + pattern (str): The pattern to update + **kwargs: Configuration options to update + """ + if pattern not in self.completers: + return + + config = self.completers[pattern] + config["config"].update(kwargs) + + # Apply updates to completer + completer: QCompleter = config["completer"] + + if "case_sensitive" in kwargs: + case_sensitivity = ( + Qt.CaseSensitivity.CaseSensitive + if kwargs["case_sensitive"] + else Qt.CaseSensitivity.CaseInsensitive + ) + completer.setCaseSensitivity(case_sensitivity) + + if "completion_mode" in kwargs: + mode_str = kwargs["completion_mode"] + if mode_str == "inline": + completer.setCompletionMode(QCompleter.CompletionMode.InlineCompletion) + else: + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + + # Update popup settings + if any(k in kwargs for k in ["min_width", "max_width", "word_wrap"]): + popup = completer.popup() + + if "min_width" in kwargs: + popup.setMinimumWidth(kwargs["min_width"]) + if "max_width" in kwargs: + popup.setMaximumWidth(kwargs["max_width"]) + if "word_wrap" in kwargs: + from PyQt6.QtWidgets import QListView + + if isinstance(popup, QListView): + popup.setWordWrap(kwargs["word_wrap"]) + + def set_global_config(self, **kwargs): + """Update global configuration for all completers""" + self.global_config.update(kwargs) + + def set_visual_indicator(self, enabled): + """Enable/disable visual indicator globally""" + self.global_config["visual_indicator"] = enabled + + def cleanup_pattern_completer(self): + """Clean up completer resources""" + # Disconnect text changed signal first + if hasattr(self, "textChanged"): + try: + self.textChanged.disconnect(self._pattern_on_text_changed) + except (TypeError, RuntimeError): + pass + + # Clean up completers + for pattern, config in list(self.completers.items()): + try: + completer: QCompleter = config.get("completer") + if completer: + # Hide and disconnect popup + popup = completer.popup() + if popup and popup.isVisible(): + popup.hide() + + # Disconnect signals + try: + completer.activated.disconnect() + except (TypeError, RuntimeError): + pass + + # Delete completer + completer.setWidget(None) + completer.setModel(None) + completer.deleteLater() + except (TypeError, RuntimeError, AttributeError) as e: + print(f"Error cleaning up completer for pattern '{pattern}': {e}") + pass + + self.completers.clear() + + def _get_text_and_cursor(self): + """Get text and cursor position (works for different widget types)""" + text = self.toPlainText() if hasattr(self, "toPlainText") else self.text() + + if hasattr(self, "textCursor"): + cursor_pos: QTextCursor = self.textCursor().position() + else: + cursor_pos = self.cursorPosition() + + return text, cursor_pos + + def _set_text_with_cursor(self, text, cursor_pos): + """Set text and cursor position (works for different widget types)""" + if hasattr(self, "setPlainText"): + self.setPlainText(text) + cursor: QTextCursor = self.textCursor() + cursor.setPosition(min(cursor_pos, len(text))) + self.setTextCursor(cursor) + else: + self.setText(text) + self.setCursorPosition(min(cursor_pos, len(text))) + + def _find_active_trigger(self, text, cursor_pos): + """Find which trigger pattern is currently active (optimized)""" + active_pattern = None + last_trigger_pos = -1 + + search_text: str = text[:cursor_pos] + + for pattern in self.completers.keys(): + # Search backwards from cursor for efficiency + pos = search_text.rfind(pattern) + + while pos >= 0: + end_pos = pos + len(pattern) + + # Validate end_pos + if end_pos > len(text): + pos = search_text.rfind(pattern, 0, pos) + continue + + text_after = text[end_pos:cursor_pos] + + # Check if there's no space or newline after trigger + if " " not in text_after and "\n" not in text_after: + if pos > last_trigger_pos: + last_trigger_pos = pos + active_pattern = pattern + break + + # Search for earlier occurrence + pos = search_text.rfind(pattern, 0, pos) + + return active_pattern, last_trigger_pos + + def _apply_visual_indicator(self, active): + """Apply visual styling""" + config = self.global_config + if not config.get("visual_indicator", False): + return + + if active: + # Use object name for more reliable styling + if not self.objectName(): + self.setObjectName("pattern_completer_widget") + self.setStyleSheet( + "#pattern_completer_widget { border: 2px solid #4CAF50; border-radius: 3px; }" + ) + else: + self.setStyleSheet("") + + def _pattern_on_text_changed(self): + """Handle text changes""" + if self.inserting_completion: + return + + try: + text, cursor_pos = self._get_text_and_cursor() + except (RuntimeError, AttributeError): + return + + active_pattern, trigger_pos = self._find_active_trigger(text, cursor_pos) + + if active_pattern and trigger_pos >= 0: + self.active_pattern = active_pattern + self.trigger_start_pos = trigger_pos + + pattern_config = self.completers[active_pattern] + completer: QCompleter = pattern_config["completer"] + config = pattern_config["config"] + + pattern_len = len(active_pattern) + prefix = text[trigger_pos + pattern_len : cursor_pos] + + completer.setCompletionPrefix(prefix) + completer.setWidget(self) + + # Calculate optimal width based on content + popup = completer.popup() + popup.setUpdatesEnabled(False) + + # Position the popup at the cursor for multi-line widgets + if hasattr(self, "cursorRect"): + # QTextEdit/QPlainTextEdit - position at cursor + cursor_rect: QRect = self.cursorRect() + popup_pos = self.mapToGlobal(cursor_rect.bottomLeft()) + popup.move(popup_pos) + completer.complete(cursor_rect) + else: + # QLineEdit - default positioning is fine + completer.complete() + + # Auto-resize popup width to fit content using font metrics + if config.get("auto_resize", True) and completer.completionCount() > 0: + # Get font metrics from the popup + font_metrics = QFontMetrics(popup.font()) + + max_width = config.get("min_width", 150) + padding = config.get("padding", 20) + + for i in range(completer.completionCount()): + index = completer.completionModel().index(i, 0) + item_text = completer.completionModel().data(index) + if item_text: + # Get actual pixel width of the text + text_width = font_metrics.horizontalAdvance(str(item_text)) + max_width = max(max_width, text_width + padding) + + # Set width with limits + max_limit = config.get("max_width", 500) + max_width = min(max_width, max_limit) + popup.setFixedWidth(max_width) + + popup.setUpdatesEnabled(True) + + if config.get("visual_indicator", False): + self._apply_visual_indicator(True) + else: + self.active_pattern = None + self.trigger_start_pos = -1 + + for pattern_config in self.completers.values(): + try: + if pattern_config["completer"].popup().isVisible(): + pattern_config["completer"].popup().hide() + except (RuntimeError, AttributeError): + pass + + self._apply_visual_indicator(False) + + def _pattern_insert_completion(self, completion): + """Insert the selected completion""" + if self.trigger_start_pos < 0 or not self.active_pattern: + return + + self.inserting_completion = True + + try: + # Remove the trigger pattern and any text after it up to cursor + text, cursor_pos = self._get_text_and_cursor() + + # Replace with just the completion (without the pattern prefix) + new_text = text[: self.trigger_start_pos] + completion + text[cursor_pos:] + + new_cursor_pos = self.trigger_start_pos + len(completion) + self._set_text_with_cursor(new_text, new_cursor_pos) + + # Reset state BEFORE hiding popup to prevent re-triggering + self.trigger_start_pos = -1 + self.active_pattern = None + + # Hide all popups + for pattern_config in self.completers.values(): + try: + if pattern_config["completer"].popup().isVisible(): + pattern_config["completer"].popup().hide() + except (RuntimeError, AttributeError): + pass + + self._apply_visual_indicator(False) + finally: + self.inserting_completion = False + + def _pattern_key_press_event(self, event): + """ + Handle pattern completion keys. + Call this from your widget's keyPressEvent BEFORE calling super(). + + Returns: + bool: True if event was handled (don't call super), False otherwise + """ + # Check for active completer + completer_visible = False + active_completer: QCompleter = None + + for pattern, pattern_config in self.completers.items(): + if pattern_config["completer"].popup().isVisible(): + completer_visible = True + if pattern == self.active_pattern: + active_completer = pattern_config["completer"] + break + + if completer_visible and active_completer: + if event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): + index = active_completer.popup().currentIndex() + if index.isValid(): + completion = active_completer.completionModel().data(index) + self._pattern_insert_completion(completion) + event.accept() + return True # Event handled + elif event.key() == Qt.Key.Key_Escape: + active_completer.popup().hide() + self._apply_visual_indicator(False) + event.accept() + return True # Event handled + + return False # Event not handled, continue normal processing + + +class PatternLineEdit(QLineEdit, PatternCompleter): + """QLineEdit with pattern completion""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self.init_pattern_completer(**kwargs) + + def keyPressEvent(self, event): + """Override to handle completion keys""" + if not self._pattern_key_press_event(event): + # Event not handled by pattern completer, process normally + super().keyPressEvent(event) + + +class PatternTextEdit(QTextEdit, PatternCompleter): + """QTextEdit with pattern completion""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self.init_pattern_completer(**kwargs) + + def keyPressEvent(self, event): + """Override to handle completion keys""" + if not self._pattern_key_press_event(event): + # Event not handled by pattern completer, process normally + super().keyPressEvent(event) + + +class PatternPlainTextEdit(QPlainTextEdit, PatternCompleter): + """QPlainTextEdit with pattern completion""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self.init_pattern_completer(**kwargs) + + def keyPressEvent(self, event): + """Override to handle completion keys""" + if not self._pattern_key_press_event(event): + # Event not handled by pattern completer, process normally + super().keyPressEvent(event)