Add missing ui_elements.py (was excluded by .gitignore)
This commit is contained in:
277
src/ui/ui_elements.py
Normal file
277
src/ui/ui_elements.py
Normal 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
|
||||||
Reference in New Issue
Block a user