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