diff --git a/src/pymodaq_gui/examples/parameter_ex.py b/src/pymodaq_gui/examples/parameter_ex.py index 80840523..836726b1 100644 --- a/src/pymodaq_gui/examples/parameter_ex.py +++ b/src/pymodaq_gui/examples/parameter_ex.py @@ -23,6 +23,25 @@ def addNew(self, full_path:tuple): # Need to register a new type to properly trigger addNew registerParameterType('groupedit', ScalableGroup, override=True) +def create_text_with_pattern_parameter(): + text_params = { + "name": "text_with_pattern", + "title": "Text Editing with pattern completion (@ for users, # for languages):", + "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 = [ @@ -117,6 +136,7 @@ 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_with_pattern_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'}, ]}, diff --git a/src/pymodaq_gui/examples/pattern_completer_demo.py b/src/pymodaq_gui/examples/pattern_completer_demo.py new file mode 100644 index 00000000..f98cb357 --- /dev/null +++ b/src/pymodaq_gui/examples/pattern_completer_demo.py @@ -0,0 +1,426 @@ +""" +PatternCompleter Examples - PyQt6 Auto-completion System + +This demonstrates various use cases for the PatternCompleter mixin class. +All examples are accessible through tabs in a single window. +""" + +from qtpy.QtWidgets import ( + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QLabel, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QHBoxLayout, + QPushButton, +) +from qtpy.QtCore import QTimer +from pymodaq_gui.utils.widgets.pattern_completer import ( + PatternLineEdit, + PatternTextEdit, + PatternPlainTextEdit, + PatternCompleterDelegate, +) +import sys + + +# ============================================================================ +# EXAMPLE 1: Basic Usage with QLineEdit +# ============================================================================ +def create_basic_example(): + """Simple mention system with @ trigger""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 1: Basic Usage")) + layout.addWidget(QLabel("Type @ to mention someone")) + + # Create a line edit with pattern completion + line_edit = PatternLineEdit() + + # Add @ mentions completer + users = ["Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince"] + line_edit.add_completer("@", users) + + line_edit.setPlaceholderText("Type @ to mention someone...") + layout.addWidget(line_edit) + layout.addStretch() + + return widget + + +# ============================================================================ +# EXAMPLE 2: Multiple Patterns +# ============================================================================ +def create_multiple_patterns_example(): + """Text editor with multiple completion triggers""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 2: Multiple Patterns")) + layout.addWidget(QLabel("Try typing @ for mentions, # for hashtags, :: for emojis")) + + text_edit = PatternTextEdit() + + # @ for user mentions + users = ["Alice", "Bob", "Charlie", "Diana"] + text_edit.add_completer("@", users) + + # # for hashtags + tags = ["python", "pyqt6", "programming", "development", "tutorial"] + text_edit.add_completer("#", tags) + + # :: for simple symbols + # Using only simple single-character symbols to avoid cursor positioning issues + symbols = [ + "check ✓", + "cross ✗", + "star ★", + "arrow →", + "bullet •", + "circle ○", + "square ■", + "plus ✚", + ] + text_edit.add_completer("::", symbols) + + text_edit.setPlaceholderText( + "Try typing:\n @ for mentions\n # for hashtags\n :: for symbols" + ) + layout.addWidget(text_edit) + return widget + + +# ============================================================================ +# EXAMPLE 3: Global Configuration +# ============================================================================ +def create_global_config_example(): + """Configure appearance and behavior globally""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 3: Global Configuration")) + layout.addWidget( + QLabel("Notice the green border when typing @ (visual indicator enabled)") + ) + + # Initialize with global settings + line_edit = PatternLineEdit( + min_width=200, # Minimum popup width + max_width=600, # Maximum popup width + visual_indicator=True, # Show green border when active + case_sensitive=False, # Case-insensitive by default + auto_resize=True, # Auto-resize popup to fit content + word_wrap=False, # Don't wrap long items + ) + + countries = ["United States", "United Kingdom", "Canada", "Australia", "Germany"] + line_edit.add_completer("@", countries) + + line_edit.setPlaceholderText("Type @ - notice the green border!") + layout.addWidget(line_edit) + layout.addStretch() + + return widget + + +# ============================================================================ +# EXAMPLE 4: Per-Pattern Configuration +# ============================================================================ +def create_per_pattern_config_example(): + """Different settings for each pattern""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 4: Per-Pattern Configuration")) + layout.addWidget(QLabel("@ is case-insensitive, :: is case-sensitive")) + + text_edit = PatternTextEdit() + + # Case-insensitive user mentions with visual indicator + users = ["Alice", "Bob", "Charlie"] + text_edit.add_completer("@", users, visual_indicator=True, case_sensitive=False) + + # Case-sensitive programming keywords + keywords = ["def", "class", "import", "return", "if", "else"] + text_edit.add_completer( + "::", + keywords, + case_sensitive=True, # Exact case matching + min_width=150, + max_width=300, + ) + + text_edit.setPlaceholderText( + "@ mentions are case-insensitive (try '@ali' or '@ALI')\n" + ":: keywords are case-sensitive (try '::def' vs '::DEF')" + ) + layout.addWidget(text_edit) + + return widget + + +# ============================================================================ +# EXAMPLE 5: Word Wrap Example - Side by Side Comparison +# ============================================================================ +def create_word_wrap_example(): + """ + Demonstrates word wrap feature for long completion items with side-by-side comparison + """ + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 5: Word Wrap - Side by Side Comparison")) + + info_label = QLabel( + "What is Word Wrap?
" + "• word_wrap=False (left): Long items are truncated or need scrolling
" + "• word_wrap=True (right): Long items wrap to multiple lines in popup

" + "Type @ in either field to see the difference side-by-side!
" + "Both popups will appear simultaneously for comparison." + ) + layout.addWidget(info_label) + + # Create side-by-side layout + compare_layout = QHBoxLayout() + + # Left side - WITHOUT word wrap + left_layout = QVBoxLayout() + left_layout.addWidget(QLabel("WITHOUT Word Wrap")) + line_edit_no_wrap = PatternLineEdit() + + # Long completion items that benefit from word wrap + long_items = [ + "Alice Johnson - Senior Developer at Tech Corp", + "Bob Smith - Product Manager with 10 years experience", + "Charlie Brown - UX Designer specializing in mobile applications", + "Diana Prince - Data Scientist working on machine learning projects", + "Eve Martinez - Full Stack Engineer with cloud expertise", + ] + + line_edit_no_wrap.add_completer("@", long_items, word_wrap=False, max_width=300) + line_edit_no_wrap.setPlaceholderText("Type @ - items truncated") + left_layout.addWidget(line_edit_no_wrap) + + # Right side - WITH word wrap + right_layout = QVBoxLayout() + right_layout.addWidget(QLabel("WITH Word Wrap")) + line_edit_with_wrap = PatternLineEdit() + line_edit_with_wrap.add_completer("@", long_items, word_wrap=True, max_width=300) + line_edit_with_wrap.setPlaceholderText("Type @ - items wrap") + right_layout.addWidget(line_edit_with_wrap) + + # Mirror the text between both fields + def sync_left_to_right(text): + if line_edit_with_wrap.text() != text: + line_edit_with_wrap.setText(text) + line_edit_with_wrap.setCursorPosition(line_edit_no_wrap.cursorPosition()) + line_edit_no_wrap.setFocus() + + def sync_right_to_left(text): + if line_edit_no_wrap.text() != text: + line_edit_no_wrap.setText(text) + line_edit_no_wrap.setCursorPosition(line_edit_with_wrap.cursorPosition()) + line_edit_with_wrap.setFocus() + + line_edit_no_wrap.textChanged.connect(sync_left_to_right) + line_edit_with_wrap.textChanged.connect(sync_right_to_left) + + # Add both to compare layout + compare_layout.addLayout(left_layout) + compare_layout.addLayout(right_layout) + layout.addLayout(compare_layout) + + + # Add instruction label + instruction_label = QLabel( + "Notice: The first item is automatically highlighted (selected) in both popups.
" + "Press Tab or Enter to accept the highlighted suggestion!
" + ) + instruction_label.setWordWrap(True) + layout.addWidget(instruction_label) + + layout.addStretch() + + return widget + + +# ============================================================================ +# EXAMPLE 6: Dynamic Updates +# ============================================================================ +def create_dynamic_updates_example(): + """Update completions dynamically""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 6: Dynamic Updates")) + counter_label = QLabel("Completions update every 2 seconds!") + layout.addWidget(counter_label) + + text_edit = PatternTextEdit() + text_edit.add_completer("@", ["Alice", "Bob"]) + text_edit.setPlaceholderText("Type @ to see completions. Watch them change!") + layout.addWidget(text_edit) + + # Simulate dynamic updates + counter = [0] + + def update_users(): + counter[0] += 1 + new_users = [f"User{i}" for i in range(counter[0], counter[0] + 5)] + text_edit.update_completions("@", new_users) + counter_label.setText(f"Update #{counter[0]}: {', '.join(new_users)}") + + timer = QTimer(widget) + timer.timeout.connect(update_users) + timer.start(2000) + + return widget + + +# ============================================================================ +# EXAMPLE 7: Table Widget with Delegate +# ============================================================================ +def create_table_delegate_example(): + """Use PatternCompleter in table cells""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 7: Table Delegate")) + layout.addWidget(QLabel("Double-click cells and type @ for users or # for tags")) + + table = QTableWidget(5, 2) + table.setHorizontalHeaderLabels(["Assigned To", "Tags"]) + + # Create delegate with pattern completion + delegate = PatternCompleterDelegate(min_width=200, visual_indicator=True) + # Add completers for the delegate + users = ["Alice", "Bob", "Charlie", "Diana"] + tags = ["urgent", "review", "bug", "feature", "documentation"] + delegate.add_completer("@", users) + delegate.add_completer("#", tags) + + # Apply delegate to both columns + table.setItemDelegateForColumn(0, delegate) + table.setItemDelegateForColumn(1, delegate) + # It is possible to use different delegates for each column but one has to keep the instance alive (with self. ...) + + # Add some sample data + for i in range(5): + table.setItem(i, 0, QTableWidgetItem(f"Task {i + 1}")) + table.setItem(i, 1, QTableWidgetItem("")) + + layout.addWidget(table) + + return widget + + +# ============================================================================ +# EXAMPLE 8: Code Editor Style +# ============================================================================ +def create_code_editor_example(): + """IDE-like code completion""" + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Example 8: Code Editor Style")) + layout.addWidget(QLabel("Python-style completion: :keyword or ::builtin")) + + editor = PatternPlainTextEdit( + min_width=250, max_width=500, case_sensitive=True, auto_resize=True + ) + + # Python keywords + keywords = [ + "def", + "class", + "import", + "from", + "return", + "if", + "else", + "elif", + "for", + "while", + "try", + "except", + "with", + "as", + "pass", + "break", + ] + + # Built-in functions + builtins = [ + "print()", + "len()", + "range()", + "enumerate()", + "zip()", + "map()", + "filter()", + "sorted()", + "sum()", + "max()", + "min()", + ] + + editor.add_completer(":", keywords, case_sensitive=True) + editor.add_completer("::", builtins) + + editor.setPlaceholderText( + "Python-style completion:\n" + " :def → keywords\n" + " ::print → built-in functions\n\n" + "Try typing ':for' or '::pri'" + ) + + layout.addWidget(editor) + + return widget + + +# ============================================================================ +# Main Application with Tabs +# ============================================================================ +class PatternCompleterDemo(QMainWindow): + """Main demo window with all examples in tabs""" + + def __init__(self): + super().__init__() + self.setWindowTitle("PatternCompleter Examples - Interactive Demo") + self.setGeometry(100, 100, 900, 600) + + # Create tab widget + tabs = QTabWidget() + + # Add all example tabs + tabs.addTab(create_basic_example(), "1. Basic") + tabs.addTab(create_multiple_patterns_example(), "2. Multiple Patterns") + tabs.addTab(create_global_config_example(), "3. Global Config") + tabs.addTab(create_per_pattern_config_example(), "4. Per-Pattern Config") + tabs.addTab(create_word_wrap_example(), "5. Word Wrap") + tabs.addTab(create_dynamic_updates_example(), "6. Dynamic Updates") + tabs.addTab(create_table_delegate_example(), "7. Table Delegate") + tabs.addTab(create_code_editor_example(), "8. Code Editor") #Not working currently + + self.setCentralWidget(tabs) + + +# ============================================================================ +# Run Application +# ============================================================================ +if __name__ == "__main__": + app = QApplication(sys.argv) + + # Set application style + app.setStyle("Fusion") + + # Create and show demo window + demo = PatternCompleterDemo() + demo.show() + + sys.exit(app.exec()) diff --git a/src/pymodaq_gui/parameter/ioxml.py b/src/pymodaq_gui/parameter/ioxml.py index 662020f6..cc8e7a41 100644 --- a/src/pymodaq_gui/parameter/ioxml.py +++ b/src/pymodaq_gui/parameter/ioxml.py @@ -221,6 +221,12 @@ def dict_from_param(param): filetype = '0' opts.update(dict(filetype=filetype)) + if 'patterns' in param.opts: + opts.update(dict(patterns=param.opts["patterns"])) + + if 'completer_config' in param.opts: + opts.update(dict(completer_config=param.opts["completer_config"])) + return opts @@ -325,6 +331,20 @@ def elt_to_dict(el): except: pass + if 'patterns' in el.attrib.keys(): + try: + patterns = eval(el.get('patterns')) + param.update(dict(patterns=patterns)) + except: + pass + + if 'completer_config' in el.attrib.keys(): + try: + completer_config = eval(el.get('completer_config')) + param.update(dict(completer_config=completer_config)) + except: + pass + return param diff --git a/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py b/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py index 9b76874d..e4c09b2d 100644 --- a/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/__init__.py @@ -11,6 +11,7 @@ from .itemselect import ItemSelectParameter from .filedir import FileDirParameter from .text import PlainTextPbParameter +from .text_pattern import PatternParameter from .numeric import NumericParameter from .group import GroupParameter from pyqtgraph.parametertree.Parameter import registerParameterType, registerParameterItemType, Parameter @@ -36,6 +37,6 @@ registerParameterType('itemselect', ItemSelectParameter, override=True) registerParameterType('browsepath', FileDirParameter, override=True) registerParameterType('text_pb', PlainTextPbParameter, override=True) +registerParameterType("text_pattern", PatternParameter) -registerParameterType('text_pb', PlainTextPbParameter, override=True) registerParameterType('group', GroupParameter, override=True) 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..9fb037a6 --- /dev/null +++ b/src/pymodaq_gui/parameter/pymodaq_ptypes/text_pattern.py @@ -0,0 +1,484 @@ +""" +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, + }, + }, + { + "name": "Tags", + "type": "text_pattern", + "value": "", + "patterns": {"#": ["urgent", "todo", "done", "in-progress"]}, + "completer_config": {"min_width": 150, "case_sensitive": False}, + }, + ], + }, + ] + + # 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/pattern_completer.py b/src/pymodaq_gui/utils/widgets/pattern_completer.py new file mode 100644 index 00000000..861efa8f --- /dev/null +++ b/src/pymodaq_gui/utils/widgets/pattern_completer.py @@ -0,0 +1,646 @@ +from qtpy.QtWidgets import ( + QWidget, + QCompleter, + QLineEdit, + QTextEdit, + QPlainTextEdit, + QStyledItemDelegate, + 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. + + Handles overlapping patterns (e.g., : and ::) by prioritizing: + 1. Patterns that appear later in the text + 2. Longer patterns over shorter ones at the same position + """ + # Sort patterns by length (longest first) to check longer patterns first + sorted_patterns = sorted(self.completers.keys(), key=len, reverse=True) + + active_pattern = None + trigger_pos = -1 + trigger_end = -1 + + search_text = text[:cursor_pos] + + for pattern in sorted_patterns: + pos = search_text.rfind(pattern) + + while pos >= 0: + end_pos = pos + len(pattern) + + # Validate end_pos doesn't exceed text length + if end_pos > len(text): + pos = search_text.rfind(pattern, 0, pos) + continue + + text_after = text[end_pos:cursor_pos] + + # Only consider if no space/newline after trigger + if " " not in text_after and "\n" not in text_after: + # Skip if this pattern overlaps with an already found longer pattern + # E.g., skip : at pos 1 if we already found :: at pos 0 + if trigger_pos >= 0 and pos >= trigger_pos and pos < trigger_end: + pos = search_text.rfind(pattern, 0, pos) + continue + + # Skip if a longer pattern exists at the same position + # E.g., skip : at pos 0 if :: also exists at pos 0 + longer_exists = any( + len(other) > len(pattern) + and search_text[pos:].startswith(other) + and pos + len(other) <= cursor_pos + for other in sorted_patterns + ) + + if longer_exists: + pos = search_text.rfind(pattern, 0, pos) + continue + + # Accept this pattern (later position or same position but longer) + if pos > trigger_pos or (pos == trigger_pos and len(pattern) > len(active_pattern)): + trigger_pos = pos + trigger_end = end_pos + active_pattern = pattern + break + + pos = search_text.rfind(pattern, 0, pos) + + return active_pattern, 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: + # If pattern changed, hide popups from other patterns + if self.active_pattern != active_pattern: + for pattern, pattern_config in self.completers.items(): + if pattern != active_pattern: + try: + if pattern_config["completer"].popup().isVisible(): + pattern_config["completer"].popup().hide() + except (RuntimeError, AttributeError): + pass + + 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-select first item to indicate it will be chosen + if completer.completionCount() > 0: + popup.setCurrentIndex(completer.completionModel().index(0, 0)) + + # 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 + """ + # Find the active completer with a visible popup + active_completer:QCompleter = None + for pattern, pattern_config in self.completers.items(): + if pattern_config["completer"].popup().isVisible(): + if pattern == self.active_pattern: + active_completer = pattern_config["completer"] + break + + if active_completer: + if event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Tab): + # Get current index, or use first item if none selected + index = active_completer.popup().currentIndex() + if not index.isValid(): + # Auto-select first item if nothing selected + index = active_completer.completionModel().index(0, 0) + + 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) + + +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 diff --git a/tests/parameter_test/parameter_text_pattern_test.py b/tests/parameter_test/parameter_text_pattern_test.py new file mode 100644 index 00000000..f9db83af --- /dev/null +++ b/tests/parameter_test/parameter_text_pattern_test.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +""" +Tests for text_pattern parameter type + +@author: PyMoDAQ Contributors +""" + +import pytest +from qtpy import QtWidgets +from pymodaq_gui.parameter import Parameter, ParameterTree +from pymodaq_gui.parameter.pymodaq_ptypes.text_pattern import ( + PatternParameter, + PatternParameterItem +) + + +@pytest.fixture +def parameter_tree(qtbot): + """Create a ParameterTree widget for testing""" + form = QtWidgets.QWidget() + tree = ParameterTree(form) + form.show() + qtbot.addWidget(form) + yield tree + form.close() + + +class TestPatternParameter: + """Test PatternParameter class""" + + def test_create_basic_parameter(self): + """Test creating a basic text_pattern parameter""" + param = Parameter.create( + name='test_pattern', + type='text_pattern', + value='Hello world', + patterns={'@': ['alice', 'bob']}, + completer_config={'min_width': 200} + ) + + assert param.name() == 'test_pattern' + assert param.type() == 'text_pattern' + assert param.value() == 'Hello world' + assert param.opts['patterns'] == {'@': ['alice', 'bob']} + assert param.opts['completer_config'] == {'min_width': 200} + + def test_default_empty_patterns(self): + """Test that patterns default to empty dict""" + param = Parameter.create( + name='test', + type='text_pattern', + value='' + ) + + assert param.opts['patterns'] == {} + assert param.opts['completer_config'] == {} + + def test_add_pattern_method(self): + """Test add_pattern convenience method""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice']} + ) + + param.add_pattern('#', ['python', 'java']) + + assert '@' in param.opts['patterns'] + assert '#' in param.opts['patterns'] + assert param.opts['patterns']['#'] == ['python', 'java'] + + def test_update_completions_method(self): + """Test update_completions convenience method""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice', 'bob']} + ) + + param.update_completions('@', ['alice', 'bob', 'charlie']) + + assert param.opts['patterns']['@'] == ['alice', 'bob', 'charlie'] + + def test_remove_pattern_method(self): + """Test remove_pattern convenience method""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice'], '#': ['python']} + ) + + param.remove_pattern('#') + + assert '@' in param.opts['patterns'] + assert '#' not in param.opts['patterns'] + + def test_set_completer_config_method(self): + """Test set_completer_config convenience method""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + completer_config={'min_width': 150} + ) + + param.set_completer_config(min_width=300, max_width=600) + + assert param.opts['completer_config']['min_width'] == 300 + assert param.opts['completer_config']['max_width'] == 600 + + def test_setOpts_patterns(self): + """Test updating patterns using setOpts""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice']} + ) + + new_patterns = {'@': ['alice', 'bob'], '#': ['python']} + param.setOpts(patterns=new_patterns) + + assert param.opts['patterns'] == new_patterns + + def test_setOpts_completer_config(self): + """Test updating completer_config using setOpts""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + completer_config={'min_width': 150} + ) + + new_config = {'min_width': 300, 'case_sensitive': True} + param.setOpts(completer_config=new_config) + + assert param.opts['completer_config'] == new_config + + +class TestPatternParameterItem: + """Test PatternParameterItem (widget integration)""" + + def test_create_widget(self, parameter_tree): + """Test that parameter item creates the correct widget""" + param = Parameter.create( + name='test', + type='text_pattern', + value='Hello', + patterns={'@': ['alice', 'bob'], '#': ['python', 'java']}, + completer_config={'min_width': 200, 'max_width': 400} + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + + assert len(items) > 0 + param_item = items[0] + assert isinstance(param_item, PatternParameterItem) + assert hasattr(param_item, 'widget') + + def test_widget_has_completers(self, parameter_tree): + """Test that widget has completers configured""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice', 'bob'], '#': ['python']} + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + # Widget should have completers attribute + assert hasattr(widget, 'completers') + assert '@' in widget.completers + assert '#' in widget.completers + + def test_widget_completions_match_parameter(self, parameter_tree): + """Test that widget completions match parameter options""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice', 'bob', 'charlie']} + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + completions = widget.completers['@']['completions'] + assert completions == ['alice', 'bob', 'charlie'] + + def test_value_changes_propagate(self, parameter_tree, qtbot): + """Test that value changes in widget propagate to parameter""" + param = Parameter.create( + name='test', + type='text_pattern', + value='Initial' + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + # Change text in widget + widget.setPlainText('Modified text') + qtbot.wait(50) + + # Value should update in parameter + assert param.value() == 'Modified text' + + def test_optsChanged_updates_patterns(self, parameter_tree, qtbot): + """Test that changing patterns via setOpts updates the widget""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice']} + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + # Initially should have @ pattern + assert '@' in widget.completers + assert '#' not in widget.completers + + # Add new pattern via setOpts + new_patterns = {'@': ['alice', 'bob'], '#': ['python']} + param.setOpts(patterns=new_patterns) + qtbot.wait(50) + + # Widget should update + assert '@' in widget.completers + assert '#' in widget.completers + assert widget.completers['@']['completions'] == ['alice', 'bob'] + + def test_optsChanged_removes_patterns(self, parameter_tree, qtbot): + """Test that removing patterns via setOpts updates the widget""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice'], '#': ['python']} + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + # Both patterns should exist + assert '@' in widget.completers + assert '#' in widget.completers + + # Remove # pattern + param.setOpts(patterns={'@': ['alice']}) + qtbot.wait(50) + + # # pattern should be removed + assert '@' in widget.completers + assert '#' not in widget.completers + + def test_optsChanged_updates_config(self, parameter_tree, qtbot): + """Test that changing completer_config via setOpts updates the widget""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + completer_config={'min_width': 150} + ) + + parameter_tree.setParameters(param, showTop=False) + + # Update config + param.setOpts(completer_config={'min_width': 300, 'max_width': 600}) + qtbot.wait(50) + + # Config should be updated (checking via parameter opts) + assert param.opts['completer_config']['min_width'] == 300 + assert param.opts['completer_config']['max_width'] == 600 + + +class TestPatternParameterIntegration: + """Integration tests for text_pattern parameter""" + + def test_full_example_from_parameter_ex(self, parameter_tree): + """Test the example from parameter_ex.py""" + text_params = { + "name": "text_with_pattern", + "title": "Text Editing with pattern completion", + "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, + }, + } + + param = Parameter.create(**text_params) + parameter_tree.setParameters(param, showTop=False) + + assert param.value() == "" + assert '@' in param.opts['patterns'] + assert '#' in param.opts['patterns'] + assert param.opts['completer_config']['min_width'] == 200 + + def test_parameter_in_group(self, parameter_tree): + """Test text_pattern parameter as child in a group""" + params = [ + { + 'title': 'Text Group', + 'name': 'text_group', + 'type': 'group', + 'children': [ + { + 'name': 'text_pattern_child', + 'type': 'text_pattern', + 'value': 'Test', + 'patterns': {'@': ['alice']}, + } + ] + } + ] + + root = Parameter.create(name='root', type='group', children=params) + parameter_tree.setParameters(root, showTop=False) + + text_param = root.child('text_group', 'text_pattern_child') + assert text_param.type() == 'text_pattern' + assert text_param.value() == 'Test' + + def test_dynamic_pattern_updates(self, parameter_tree, qtbot): + """Test dynamically updating patterns after creation""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={'@': ['alice', 'bob']} + ) + + parameter_tree.setParameters(param, showTop=False) + + # Add more users dynamically + param.update_completions('@', ['alice', 'bob', 'charlie', 'david']) + qtbot.wait(50) + + assert param.opts['patterns']['@'] == ['alice', 'bob', 'charlie', 'david'] + + param.setOpts(patterns={"@": ["alice",]}) + + assert param.opts["patterns"]["@"] == ["alice",] + # Add new pattern + param.add_pattern('#', ['python', 'java']) + qtbot.wait(50) + + assert '#' in param.opts['patterns'] + + def test_empty_patterns_allowed(self, parameter_tree): + """Test that empty patterns are allowed""" + param = Parameter.create( + name='test', + type='text_pattern', + value='Just plain text', + patterns={} + ) + + parameter_tree.setParameters(param, showTop=False) + assert param.value() == 'Just plain text' + assert param.opts['patterns'] == {} + + def test_pattern_with_special_characters(self, parameter_tree): + """Test patterns with special characters""" + param = Parameter.create( + name='test', + type='text_pattern', + value='', + patterns={ + '::': ['function', 'method'], # Double colon + '->': ['pointer', 'arrow'], # Arrow + '$': ['dollar', 'variable'] # Dollar sign + } + ) + + parameter_tree.setParameters(param, showTop=False) + items = parameter_tree.listAllItems() + widget = items[0].widget + + assert '::' in widget.completers + assert '->' in widget.completers + assert '$' in widget.completers diff --git a/tests/utils/widgets/pattern_completer_test.py b/tests/utils/widgets/pattern_completer_test.py new file mode 100644 index 00000000..23ff164d --- /dev/null +++ b/tests/utils/widgets/pattern_completer_test.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +""" +Tests for pattern_completer module + +@author: PyMoDAQ Contributors +""" + +import pytest +from qtpy import QtWidgets, QtCore, QtGui +from pymodaq_gui.utils.widgets.pattern_completer import ( + PatternLineEdit, + PatternPlainTextEdit, + PatternTextEdit, + PatternCompleterDelegate +) + + +@pytest.fixture +def pattern_line_edit(qtbot): + """Create a PatternLineEdit widget for testing""" + widget = PatternLineEdit() + qtbot.addWidget(widget) + widget.show() + return widget + + +@pytest.fixture +def pattern_plain_text_edit(qtbot): + """Create a PatternPlainTextEdit widget for testing""" + widget = PatternPlainTextEdit() + qtbot.addWidget(widget) + widget.show() + return widget + + +class TestPatternLineEdit: + """Test PatternLineEdit widget""" + + def test_init(self, pattern_line_edit): + """Test widget initialization""" + assert hasattr(pattern_line_edit, 'completers') + assert hasattr(pattern_line_edit, 'active_pattern') + assert hasattr(pattern_line_edit, 'trigger_start_pos') + assert pattern_line_edit.completers == {} + assert pattern_line_edit.active_pattern is None + assert pattern_line_edit.trigger_start_pos == -1 + + def test_add_completer(self, pattern_line_edit): + """Test adding a completer pattern""" + pattern_line_edit.add_completer('@', ['alice', 'bob', 'charlie']) + + assert '@' in pattern_line_edit.completers + assert pattern_line_edit.completers['@']['completions'] == ['alice', 'bob', 'charlie'] + assert 'completer' in pattern_line_edit.completers['@'] + assert 'model' in pattern_line_edit.completers['@'] + + def test_add_multiple_completers(self, pattern_line_edit): + """Test adding multiple different pattern completers""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + pattern_line_edit.add_completer('#', ['python', 'java']) + + assert '@' in pattern_line_edit.completers + assert '#' in pattern_line_edit.completers + assert len(pattern_line_edit.completers) == 2 + + def test_update_completions(self, pattern_line_edit): + """Test updating completion list for a pattern""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + pattern_line_edit.update_completions('@', ['alice', 'bob', 'charlie', 'david']) + + assert pattern_line_edit.completers['@']['completions'] == ['alice', 'bob', 'charlie', 'david'] + + def test_find_active_trigger_simple(self, pattern_line_edit): + """Test finding active trigger pattern""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + + # Simulate typing "@a" + text = "@a" + cursor_pos = 2 + + active_pattern, trigger_pos = pattern_line_edit._find_active_trigger(text, cursor_pos) + + assert active_pattern == '@' + assert trigger_pos == 0 + + def test_find_active_trigger_multiple_patterns(self, pattern_line_edit): + """Test finding active trigger with multiple patterns""" + pattern_line_edit.add_completer('@', ['alice']) + pattern_line_edit.add_completer('#', ['python']) + + # Typing "#py" should activate # pattern + text = "Hello @alice and #py" + cursor_pos = 20 + + active_pattern, trigger_pos = pattern_line_edit._find_active_trigger(text, cursor_pos) + + assert active_pattern == '#' + assert trigger_pos == 17 + + def test_find_active_trigger_with_space(self, pattern_line_edit): + """Test that trigger is not active after a space""" + pattern_line_edit.add_completer('@', ['alice']) + + # Space after trigger should deactivate it + text = "@ " + cursor_pos = 2 + + active_pattern, trigger_pos = pattern_line_edit._find_active_trigger(text, cursor_pos) + + assert active_pattern is None + assert trigger_pos == -1 + + def test_global_config(self, qtbot): + """Test global configuration options""" + widget = PatternLineEdit( + min_width=300, + max_width=600, + case_sensitive=True, + visual_indicator=True + ) + qtbot.addWidget(widget) + + assert widget.global_config['min_width'] == 300 + assert widget.global_config['max_width'] == 600 + assert widget.global_config['case_sensitive'] is True + assert widget.global_config['visual_indicator'] is True + + def test_pattern_specific_config(self, pattern_line_edit): + """Test pattern-specific configuration overrides""" + pattern_line_edit.add_completer( + '@', + ['alice', 'bob'], + case_sensitive=True, + min_width=250 + ) + + config = pattern_line_edit.completers['@']['config'] + assert config['case_sensitive'] is True + assert config['min_width'] == 250 + + def test_set_global_config(self, pattern_line_edit): + """Test updating global configuration""" + pattern_line_edit.set_global_config(min_width=400, max_width=800) + + assert pattern_line_edit.global_config['min_width'] == 400 + assert pattern_line_edit.global_config['max_width'] == 800 + + def test_cleanup(self, pattern_line_edit): + """Test cleanup of completer resources""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + pattern_line_edit.add_completer('#', ['python']) + + pattern_line_edit.cleanup_pattern_completer() + + assert len(pattern_line_edit.completers) == 0 + + +class TestPatternPlainTextEdit: + """Test PatternPlainTextEdit widget""" + + def test_init(self, pattern_plain_text_edit): + """Test widget initialization""" + assert hasattr(pattern_plain_text_edit, 'completers') + assert pattern_plain_text_edit.completers == {} + + def test_multiline_text(self, pattern_plain_text_edit): + """Test pattern completion in multiline text""" + pattern_plain_text_edit.add_completer('@', ['alice', 'bob']) + + # Set multiline text + text = "Hello @alice\nHow are you @" + pattern_plain_text_edit.setPlainText(text) + + # Move cursor to end + cursor = pattern_plain_text_edit.textCursor() + cursor.movePosition(QtGui.QTextCursor.MoveOperation.End) + pattern_plain_text_edit.setTextCursor(cursor) + + cursor_pos = cursor.position() + active_pattern, trigger_pos = pattern_plain_text_edit._find_active_trigger(text, cursor_pos) + + assert active_pattern == '@' + # The @ is at position after "How are you " + assert trigger_pos > 0 + + +class TestPatternCompleterDelegate: + """Test PatternCompleterDelegate for table editing""" + + def test_init(self): + """Test delegate initialization""" + delegate = PatternCompleterDelegate(min_width=200) + + assert delegate.global_kwargs['min_width'] == 200 + assert delegate.completer_configs == {} + + def test_add_completer(self): + """Test adding completer to delegate""" + delegate = PatternCompleterDelegate() + delegate.add_completer('@', ['alice', 'bob'], case_sensitive=True) + + assert '@' in delegate.completer_configs + assert delegate.completer_configs['@']['completions'] == ['alice', 'bob'] + assert delegate.completer_configs['@']['kwargs']['case_sensitive'] is True + + def test_update_completions(self): + """Test updating completions in delegate""" + delegate = PatternCompleterDelegate() + delegate.add_completer('@', ['alice', 'bob']) + delegate.update_completions('@', ['alice', 'bob', 'charlie']) + + assert delegate.completer_configs['@']['completions'] == ['alice', 'bob', 'charlie'] + + def test_create_editor(self, qtbot): + """Test creating editor widget from delegate""" + delegate = PatternCompleterDelegate() + delegate.add_completer('@', ['alice', 'bob']) + + # Create a parent widget + parent = QtWidgets.QWidget() + qtbot.addWidget(parent) + + # Create editor + editor = delegate.createEditor(parent, None, None) + + assert isinstance(editor, PatternLineEdit) + assert '@' in editor.completers + + +class TestPatternCompletion: + """Integration tests for pattern completion functionality""" + + def test_text_changed_triggers_completion(self, pattern_line_edit, qtbot): + """Test that typing trigger pattern activates completion""" + pattern_line_edit.add_completer('@', ['alice', 'bob', 'charlie']) + + # Type "@a" + pattern_line_edit.setText("@a") + pattern_line_edit.setCursorPosition(2) + + # Wait for text changed signal processing + qtbot.wait(50) + + assert pattern_line_edit.active_pattern == '@' + assert pattern_line_edit.trigger_start_pos == 0 + + def test_completion_popup_appears(self, pattern_line_edit, qtbot): + """Test that completion popup appears when typing""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + + # Type "@" + pattern_line_edit.setText("@") + pattern_line_edit.setCursorPosition(1) + + # Wait for popup to appear + qtbot.wait(100) + + completer = pattern_line_edit.completers['@']['completer'] + # Note: Popup visibility may be flaky in tests without full event loop + # Just verify completer is configured correctly + assert completer.widget() == pattern_line_edit + assert completer.completionPrefix() == "" + + def test_insert_completion(self, pattern_line_edit, qtbot): + """Test inserting a completion""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + + # Set up state as if we're completing + pattern_line_edit.setText("Hello @a") + pattern_line_edit.setCursorPosition(8) + pattern_line_edit.active_pattern = '@' + pattern_line_edit.trigger_start_pos = 6 + + # Simulate selecting "alice" from completion + pattern_line_edit._pattern_insert_completion("alice") + + assert pattern_line_edit.text() == "Hello alice" + assert pattern_line_edit.cursorPosition() == 11 + + def test_multiple_patterns_in_same_text(self, pattern_line_edit, qtbot): + """Test handling multiple patterns in same text""" + pattern_line_edit.add_completer('@', ['alice', 'bob']) + pattern_line_edit.add_completer('#', ['python', 'java']) + + # Type text with both patterns + pattern_line_edit.setText("@alice says #") + pattern_line_edit.setCursorPosition(13) + + qtbot.wait(50) + + # Should activate # pattern at the cursor position + assert pattern_line_edit.active_pattern == '#' + assert pattern_line_edit.trigger_start_pos == 12 + + def test_space_deactivates_completion(self, pattern_line_edit, qtbot): + """Test that adding space after trigger deactivates completion""" + pattern_line_edit.add_completer('@', ['alice']) + + # Type "@ " (trigger + space) + pattern_line_edit.setText("@ ") + pattern_line_edit.setCursorPosition(2) + + qtbot.wait(50) + + assert pattern_line_edit.active_pattern is None + assert pattern_line_edit.trigger_start_pos == -1