From b9ac3cdcc546e30502dce4f5080ab7309537cced Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 17 Nov 2025 22:46:42 -0500 Subject: [PATCH] Add missing ui_elements.py (was excluded by .gitignore) --- src/ui/ui_elements.py | 277 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/ui/ui_elements.py diff --git a/src/ui/ui_elements.py b/src/ui/ui_elements.py new file mode 100644 index 0000000..1b44004 --- /dev/null +++ b/src/ui/ui_elements.py @@ -0,0 +1,277 @@ +""" +Reusable accessible UI widgets for StormIRC. + +This module contains base widget classes with baked-in accessibility fixes +to ensure consistency and DRY principles across the application. + +All widgets are designed to work correctly with screen readers (Orca/NVDA) +and provide keyboard-first navigation. +""" + +import logging +from PySide6.QtWidgets import QTableWidget, QTextEdit, QPlainTextEdit +from PySide6.QtCore import Qt, Signal, QEvent +from PySide6.QtGui import QKeyEvent + +logger = logging.getLogger(__name__) + + +class AccessibleTextInput(QPlainTextEdit): + """ + Accessible single-line text input that works with screen readers. + + This widget replaces QLineEdit to avoid Orca word navigation bugs + (Ctrl+Left/Right causes freeze with QLineEdit). + + Features: + - Works correctly with Orca word navigation (Ctrl+Left/Right) + - Single-line mode (no newlines, Enter triggers returnPressed signal) + - Optional password mode (text masking) + - Configurable max height for single-line appearance + - Compatible with all QLineEdit use cases + + Args: + single_line: If True, restricts to single line and Enter triggers returnPressed + password: If True, masks input text (for password fields) + max_height: Maximum height in pixels (default 30 for single-line appearance) + placeholder: Placeholder text to display when empty + + Signals: + returnPressed: Emitted when Enter key pressed (single_line mode only) + textChanged: Emitted when text content changes + """ + + returnPressed = Signal() + + def __init__(self, single_line=True, password=False, max_height=30, placeholder=""): + super().__init__() + + self.single_line = single_line + self.password_mode = password + + # Configure appearance for single-line use + if single_line: + self.setMaximumHeight(max_height) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setLineWrapMode(QPlainTextEdit.NoWrap) + + # Set placeholder if provided + if placeholder: + self.setPlaceholderText(placeholder) + + # Configure password mode + if password: + # Note: QPlainTextEdit doesn't have native password mode + # We'll need to implement masking manually if needed + logger.warning("Password mode not fully implemented yet for AccessibleTextInput") + + # Set accessible properties + self.setAccessibleDescription("Text input field") + + logger.debug(f"Created AccessibleTextInput (single_line={single_line}, password={password})") + + def keyPressEvent(self, event: QKeyEvent): + """Handle key presses for single-line behavior.""" + if self.single_line: + # In single-line mode, Enter/Return triggers returnPressed signal + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + self.returnPressed.emit() + event.accept() + return + + # Let parent handle all other keys (including word navigation) + super().keyPressEvent(event) + + def insertFromMimeData(self, source): + """Handle paste operations in single-line mode.""" + if self.single_line: + # Strip newlines from pasted text + text = source.text() + text = text.replace('\n', ' ').replace('\r', ' ') + self.insertPlainText(text) + else: + super().insertFromMimeData(source) + + def text(self): + """ + Get the text content (QLineEdit-compatible method). + + Returns: + str: The text content + """ + return self.toPlainText() + + def setText(self, text): + """ + Set the text content (QLineEdit-compatible method). + + Args: + text: The text to set + """ + if self.single_line: + # Strip newlines in single-line mode + text = text.replace('\n', ' ').replace('\r', ' ') + self.setPlainText(text) + + def clear(self): + """Clear the text content (QLineEdit-compatible method).""" + self.setPlainText("") + + +class AccessibleTableWidget(QTableWidget): + """ + Table widget with consistent accessibility fixes. + + Features: + - Tab moves focus OUT of table (not between cells) for screen reader navigation + - Row selection mode (select entire rows, not individual cells) + - Strong focus policy for keyboard navigation + - Optional Delete key support for removing rows + - Consistent accessible properties + + Args: + enable_delete_key: If True, installs event filter to handle Delete key + accessible_name: Name for screen readers + accessible_description: Description for screen readers + + Signals: + deletePressed: Emitted when Delete key pressed (if enable_delete_key=True) + """ + + deletePressed = Signal() + + def __init__(self, enable_delete_key=False, accessible_name="", accessible_description=""): + super().__init__() + + self.delete_key_enabled = enable_delete_key + + # CRITICAL FIX: Disable Tab navigation between cells + # This allows Tab to move focus out of the table for screen reader users + self.setTabKeyNavigation(False) + + # Row selection mode (more accessible than cell selection) + self.setSelectionBehavior(QTableWidget.SelectRows) + self.setSelectionMode(QTableWidget.SingleSelection) + + # Strong focus policy for keyboard navigation + self.setFocusPolicy(Qt.StrongFocus) + + # Set accessible properties + if accessible_name: + self.setAccessibleName(accessible_name) + if accessible_description: + self.setAccessibleDescription(accessible_description) + + # Install event filter for Delete key if requested + if enable_delete_key: + self.installEventFilter(self) + + logger.debug(f"Created AccessibleTableWidget (delete_key={enable_delete_key})") + + def eventFilter(self, obj, event): + """Event filter to handle Delete key.""" + if obj == self and event.type() == QEvent.KeyPress: + if event.key() == Qt.Key_Delete and self.delete_key_enabled: + self.deletePressed.emit() + return True # Event handled + return super().eventFilter(obj, event) + + def keyPressEvent(self, event: QKeyEvent): + """Override to handle Enter key for activation.""" + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + # Emit itemActivated signal when Enter is pressed + current_item = self.currentItem() + if current_item: + self.itemActivated.emit(current_item) + event.accept() + else: + # Let parent handle all other keys (including Tab/Shift+Tab) + super().keyPressEvent(event) + + +class AccessibleChatDisplay(QTextEdit): + """ + Read-only chat display with screen reader navigation. + + Features: + - Arrow key navigation through messages + - Text selection with keyboard and mouse + - Link accessibility for keyboard users + - Proper focus handling for screen readers + - Read-only (no editing) + + Args: + accessible_name: Name for screen readers + accessible_description: Description for screen readers + """ + + def __init__(self, accessible_name="Chat display", accessible_description=""): + super().__init__() + + # Read-only mode + self.setReadOnly(True) + + # CRITICAL: Enable text selection and keyboard navigation for screen readers + self.setTextInteractionFlags( + Qt.TextSelectableByMouse | + Qt.TextSelectableByKeyboard | + Qt.LinksAccessibleByMouse | + Qt.LinksAccessibleByKeyboard + ) + + # Strong focus policy for keyboard navigation + self.setFocusPolicy(Qt.StrongFocus) + + # Set accessible properties + self.setAccessibleName(accessible_name) + if accessible_description: + self.setAccessibleDescription(accessible_description) + else: + self.setAccessibleDescription(f"{accessible_name} - navigate with arrow keys") + + logger.debug(f"Created AccessibleChatDisplay (name={accessible_name})") + + +class MessageInputEventFilter: + """ + Reusable event filter for message input widgets. + + Handles Enter key to send messages (Shift+Enter for newlines). + This pattern is duplicated across channel_tab.py and pm_window.py. + + Usage: + filter = MessageInputEventFilter(self.input_field, self.on_send_message) + self.input_field.installEventFilter(filter) + + Args: + input_widget: The QPlainTextEdit widget to filter events for + send_callback: Function to call when Enter is pressed (no Shift) + """ + + def __init__(self, input_widget, send_callback): + self.input_widget = input_widget + self.send_callback = send_callback + logger.debug("Created MessageInputEventFilter") + + def eventFilter(self, obj, event): + """ + Event filter to handle Enter key. + + - Enter: Send message (call send_callback) + - Shift+Enter: Insert newline (default behavior) + + Returns: + bool: True if event was handled, False to continue processing + """ + if obj == self.input_widget and event.type() == QEvent.KeyPress: + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + # Shift+Enter inserts newline (let it through) + if event.modifiers() & Qt.ShiftModifier: + return False + + # Plain Enter sends message + self.send_callback() + return True # Event handled + + return False # Event not handled