Source code for pymodaq_gui.utils.widgets.pattern_completer

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


[docs] 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() """
[docs] 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}")
[docs] 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, }
[docs] 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))
[docs] 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"])
[docs] def set_global_config(self, **kwargs): """Update global configuration for all completers""" self.global_config.update(kwargs)
[docs] def set_visual_indicator(self, enabled): """Enable/disable visual indicator globally""" self.global_config["visual_indicator"] = enabled
[docs] 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
[docs] class PatternLineEdit(QLineEdit, PatternCompleter): """QLineEdit with pattern completion""" def __init__(self, parent=None, **kwargs): super().__init__(parent) self.init_pattern_completer(**kwargs)
[docs] 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)
[docs] class PatternTextEdit(QTextEdit, PatternCompleter): """QTextEdit with pattern completion""" def __init__(self, parent=None, **kwargs): super().__init__(parent) self.init_pattern_completer(**kwargs)
[docs] 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)
[docs] class PatternPlainTextEdit(QPlainTextEdit, PatternCompleter): """QPlainTextEdit with pattern completion""" def __init__(self, parent=None, **kwargs): super().__init__(parent) self.init_pattern_completer(**kwargs)
[docs] 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)
[docs] 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
[docs] 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, }
[docs] 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
[docs] 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)
[docs] def set_global_config(self, **kwargs): """Update global configuration""" self.global_kwargs.update(kwargs)
[docs] 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)
[docs] 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
[docs] 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
[docs] 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