Add missing ui_elements.py (was excluded by .gitignore)

This commit is contained in:
Storm Dragon
2025-11-17 22:46:42 -05:00
parent e99b33cbd3
commit b9ac3cdcc5

277
src/ui/ui_elements.py Normal file
View File

@@ -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