commit 87ac31fafe879dc1c0b4731fe7b32460ece5341e Author: Storm Dragon Date: Tue Nov 11 01:02:38 2025 -0500 Initial commit. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55b3d40 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# StormIRC + +A completely accessible GUI IRC client designed for users who value both accessibility and good design. + +## Features + +- **Full Accessibility**: Built with GTK4 for excellent screen reader support +- **Keyboard Navigation**: Complete keyboard control with logical tab order +- **Smart Notifications**: System notifications with customizable highlight patterns +- **IRC Protocol Support**: Full IRC client with join/part, messaging, nick changes +- **Configurable**: Comprehensive settings for servers, accessibility, and UI preferences +- **Chat History Navigation**: Enhanced navigation for screen reader users + +## Keyboard Shortcuts + +- `F6`: Cycle through main areas (channel list, chat, input) +- `Ctrl+1`: Focus channel list +- `Ctrl+2`: Focus chat area +- `Ctrl+3`: Focus message input +- `Ctrl+J`: Quick join channel +- `Ctrl+W`: Leave current channel +- `Ctrl+N`: Next channel +- `Ctrl+P`: Previous channel +- `Ctrl+Home`: Jump to chat beginning +- `Ctrl+End`: Jump to chat end +- `Alt+Up/Down`: Scroll chat +- `Ctrl+R`: Read last messages + +## Installation + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Run the application: + ```bash + python run_stormirc.py + ``` + +## IRC Commands + +- `/join #channel` - Join a channel +- `/part` - Leave current channel +- `/nick newnick` - Change nickname +- `/quit` - Disconnect and quit + +## Configuration + +StormIRC stores its configuration in `~/.config/stormirc/config.json`. You can: + +- Add multiple IRC servers +- Configure accessibility preferences +- Set custom highlight patterns +- Adjust UI settings + +Access settings through the gear icon in the header bar. + +## Accessibility Features + +- Screen reader announcements for important events +- Logical keyboard navigation +- Proper widget labeling and descriptions +- High contrast support ready +- Customizable notification preferences +- Unread message indicators + +## Building for those panzies who need GUI + +This IRC client proves that accessibility and good UX aren't mutually exclusive. While terminal purists have irssi, this gives everyone else a proper IRC experience that works beautifully with assistive technologies. + +Built with Python, GTK4, and lots of care for the user experience. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b5ebd71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyGObject>=3.44.0 +pycairo>=1.20.0 +python-speechd>=0.11.0 \ No newline at end of file diff --git a/sounds/highlight.wav b/sounds/highlight.wav new file mode 100755 index 0000000..265a37f Binary files /dev/null and b/sounds/highlight.wav differ diff --git a/sounds/private_message.wav b/sounds/private_message.wav new file mode 100644 index 0000000..d1721d6 Binary files /dev/null and b/sounds/private_message.wav differ diff --git a/sounds/public_message.wav b/sounds/public_message.wav new file mode 100644 index 0000000..f38676f Binary files /dev/null and b/sounds/public_message.wav differ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c44ce14 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""StormIRC - Accessible IRC Client.""" diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a6f79bc Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..c0e8e92 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1 @@ +"""Configuration management modules.""" \ No newline at end of file diff --git a/src/config/__pycache__/__init__.cpython-313.pyc b/src/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..77a953f Binary files /dev/null and b/src/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/config/__pycache__/settings.cpython-313.pyc b/src/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..ff3317f Binary files /dev/null and b/src/config/__pycache__/settings.cpython-313.pyc differ diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 0000000..619d3e0 --- /dev/null +++ b/src/config/settings.py @@ -0,0 +1,334 @@ +"""Configuration management for StormIRC.""" + +import json +import logging +import os +from pathlib import Path +from typing import Dict, Any, List, Optional +from dataclasses import dataclass, asdict, field + +logger = logging.getLogger(__name__) + + +@dataclass +class ServerConfig: + """Server configuration.""" + name: str = "" + host: str = "" + port: int = 6667 + use_ssl: bool = False + nickname: str = "" + username: str = "" + realname: str = "" + password: str = "" + auto_connect: bool = False + auto_join_channels: List[str] = field(default_factory=list) + # SASL authentication + use_sasl: bool = False + sasl_username: str = "" + sasl_password: str = "" + + +@dataclass +class ChannelSpeechSettings: + """Per-channel or per-PM speech settings.""" + voice: str = "" # Voice name (empty = use default) + rate: int = 0 # Speech rate offset (-100 to 100, 0 = use default) + output_module: str = "" # Output module (empty = use default) + + +@dataclass +class AccessibilityConfig: + """Accessibility configuration.""" + enable_notifications: bool = True + enable_sounds: bool = True # Changed to True by default + announce_joins_parts: bool = True + announce_nick_changes: bool = True + mention_highlights: bool = True + chat_history_lines: int = 1000 + font_size: int = 12 + high_contrast: bool = False + screen_reader_announcements: bool = True + keyboard_shortcuts_enabled: bool = True + # Sound settings (individual control) + sound_public_message: bool = True + sound_private_message: bool = True + sound_highlight: bool = True + # Speech settings + speech_enabled: bool = True + speech_rate: int = 0 + speech_pitch: int = 0 + speech_volume: int = 0 + speech_voice: str = "" + speech_output_module: str = "" # Global output module (espeak-ng, voxin, etc.) + auto_read_messages: bool = True # Automatically read incoming messages + speech_read_own_messages: bool = False # Read your own messages aloud when sent + speech_read_timestamps: bool = False # Read timestamps aloud when show_timestamps is enabled + # Default speech settings for new channels/PMs + default_channel_voice: str = "" + default_channel_rate: int = 0 + default_channel_module: str = "" + default_pm_voice: str = "" + default_pm_rate: int = 0 + default_pm_module: str = "" + + +@dataclass +class UIConfig: + """UI configuration.""" + window_width: int = 800 + window_height: int = 600 + channel_list_width: int = 200 + show_timestamps: bool = True + timestamp_format: str = "[%H:%M:%S]" + theme: str = "default" + channel_list_position: str = "left" + + +@dataclass +class StormIRCConfig: + """Main configuration class.""" + servers: List[ServerConfig] = field(default_factory=list) + accessibility: AccessibilityConfig = field(default_factory=AccessibilityConfig) + ui: UIConfig = field(default_factory=UIConfig) + highlight_patterns: List[str] = field(default_factory=list) + last_connected_server: str = "" + function_keys: Dict[str, str] = field(default_factory=dict) # F1-F12 -> command mapping + channel_speech_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict) # server:channel -> settings + + +class ConfigManager: + """Manages configuration loading and saving.""" + + def __init__(self): + self.config_dir = Path.home() / ".config" / "stormirc" + self.config_file = self.config_dir / "config.json" + self.config = StormIRCConfig() + self.ensure_config_dir() + self.load_config() + + def ensure_config_dir(self): + """Ensure configuration directory exists.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + + def load_config(self) -> StormIRCConfig: + """Load configuration from file.""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + data = json.load(f) + + self.config = self._dict_to_config(data) + + except Exception as e: + logger.error(f"Error loading config: {e}", exc_info=True) + self.config = StormIRCConfig() + else: + self.create_default_config() + + return self.config + + def save_config(self): + """Save configuration to file.""" + try: + config_dict = self._config_to_dict(self.config) + + with open(self.config_file, 'w') as f: + json.dump(config_dict, f, indent=2) + + except Exception as e: + logger.error(f"Error saving config: {e}", exc_info=True) + + def create_default_config(self): + """Create default configuration.""" + self.config = StormIRCConfig() + + default_server = ServerConfig( + name="Libera.Chat", + host="irc.libera.chat", + port=6697, + use_ssl=True, + nickname="StormUser", + username="stormuser", + realname="Storm User" + ) + + self.config.servers.append(default_server) + self.save_config() + + def _config_to_dict(self, config: StormIRCConfig) -> Dict[str, Any]: + """Convert config to dictionary.""" + return { + 'servers': [asdict(server) for server in config.servers], + 'accessibility': asdict(config.accessibility), + 'ui': asdict(config.ui), + 'highlight_patterns': config.highlight_patterns, + 'last_connected_server': config.last_connected_server, + 'function_keys': config.function_keys, + 'channel_speech_settings': config.channel_speech_settings + } + + def _dict_to_config(self, data: Dict[str, Any]) -> StormIRCConfig: + """Convert dictionary to config.""" + config = StormIRCConfig() + + if 'servers' in data: + config.servers = [ + ServerConfig(**server_data) + for server_data in data['servers'] + ] + + if 'accessibility' in data: + # Handle backward compatibility - remove deprecated fields + accessibility_data = data['accessibility'].copy() + # Remove old speech_include_timestamps field if it exists + accessibility_data.pop('speech_include_timestamps', None) + config.accessibility = AccessibilityConfig(**accessibility_data) + + if 'ui' in data: + config.ui = UIConfig(**data['ui']) + + if 'highlight_patterns' in data: + config.highlight_patterns = data['highlight_patterns'] + + if 'last_connected_server' in data: + config.last_connected_server = data['last_connected_server'] + + if 'function_keys' in data: + config.function_keys = data['function_keys'] + + if 'channel_speech_settings' in data: + config.channel_speech_settings = data['channel_speech_settings'] + + return config + + def add_server(self, server: ServerConfig): + """Add server to configuration.""" + self.config.servers.append(server) + self.save_config() + + def remove_server(self, server_name: str): + """Remove server from configuration.""" + self.config.servers = [ + s for s in self.config.servers + if s.name != server_name + ] + self.save_config() + + def get_server(self, server_name: str) -> Optional[ServerConfig]: + """Get server configuration by name.""" + for server in self.config.servers: + if server.name == server_name: + return server + return None + + def update_server(self, server_name: str, updated_server: ServerConfig): + """Update server configuration.""" + for i, server in enumerate(self.config.servers): + if server.name == server_name: + self.config.servers[i] = updated_server + self.save_config() + break + + def get_accessibility_config(self) -> AccessibilityConfig: + """Get accessibility configuration.""" + return self.config.accessibility + + def update_accessibility_config(self, accessibility: AccessibilityConfig): + """Update accessibility configuration.""" + self.config.accessibility = accessibility + self.save_config() + + def get_ui_config(self) -> UIConfig: + """Get UI configuration.""" + return self.config.ui + + def update_ui_config(self, ui: UIConfig): + """Update UI configuration.""" + self.config.ui = ui + self.save_config() + + def add_highlight_pattern(self, pattern: str): + """Add highlight pattern.""" + if pattern not in self.config.highlight_patterns: + self.config.highlight_patterns.append(pattern) + self.save_config() + + def remove_highlight_pattern(self, pattern: str): + """Remove highlight pattern.""" + if pattern in self.config.highlight_patterns: + self.config.highlight_patterns.remove(pattern) + self.save_config() + + def export_config(self, file_path: str): + """Export configuration to file.""" + try: + config_dict = self._config_to_dict(self.config) + + with open(file_path, 'w') as f: + json.dump(config_dict, f, indent=2) + + return True + except Exception as e: + logger.error(f"Error exporting config: {e}", exc_info=True) + return False + + def import_config(self, file_path: str): + """Import configuration from file.""" + try: + with open(file_path, 'r') as f: + data = json.load(f) + + self.config = self._dict_to_config(data) + self.save_config() + return True + + except Exception as e: + logger.error(f"Error importing config: {e}", exc_info=True) + return False + + def get_channel_speech_settings(self, server_name: str, target: str) -> ChannelSpeechSettings: + """Get speech settings for a specific channel or PM.""" + key = f"{server_name}:{target}" + if key in self.config.channel_speech_settings: + settings_dict = self.config.channel_speech_settings[key] + return ChannelSpeechSettings(**settings_dict) + + # Return defaults based on whether it's a channel or PM + is_channel = target.startswith('#') or target.startswith('&') + if is_channel: + return ChannelSpeechSettings( + voice=self.config.accessibility.default_channel_voice, + rate=self.config.accessibility.default_channel_rate, + output_module=self.config.accessibility.default_channel_module + ) + else: + return ChannelSpeechSettings( + voice=self.config.accessibility.default_pm_voice, + rate=self.config.accessibility.default_pm_rate, + output_module=self.config.accessibility.default_pm_module + ) + + def set_channel_speech_settings(self, server_name: str, target: str, settings: ChannelSpeechSettings): + """Set speech settings for a specific channel or PM.""" + key = f"{server_name}:{target}" + self.config.channel_speech_settings[key] = asdict(settings) + self.save_config() + + def remove_channel_speech_settings(self, server_name: str, target: str): + """Remove custom speech settings for a channel/PM (revert to defaults).""" + key = f"{server_name}:{target}" + if key in self.config.channel_speech_settings: + del self.config.channel_speech_settings[key] + self.save_config() + + +def is_valid_text(text: str) -> bool: + """ + Check if text contains meaningful content worth speaking. + Returns True if text has alphanumeric characters. + """ + if not text: + return False + # Check if there's at least one alphanumeric character + return any(c.isalnum() for c in text) diff --git a/src/irc/__init__.py b/src/irc/__init__.py new file mode 100644 index 0000000..34c0a09 --- /dev/null +++ b/src/irc/__init__.py @@ -0,0 +1 @@ +"""IRC protocol handling modules.""" \ No newline at end of file diff --git a/src/irc/__pycache__/__init__.cpython-313.pyc b/src/irc/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..11d4bf0 Binary files /dev/null and b/src/irc/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/irc/__pycache__/client.cpython-313.pyc b/src/irc/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..715055f Binary files /dev/null and b/src/irc/__pycache__/client.cpython-313.pyc differ diff --git a/src/irc/client.py b/src/irc/client.py new file mode 100644 index 0000000..650513c --- /dev/null +++ b/src/irc/client.py @@ -0,0 +1,574 @@ +"""IRC client implementation.""" + +import socket +import ssl +import threading +import base64 +import logging +from typing import Optional, Callable, Dict, List +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class IRCMessage: + """Represents an IRC message.""" + prefix: Optional[str] = None + command: str = "" + params: List[str] = None + + def __post_init__(self): + if self.params is None: + self.params = [] + + +class IRCClient: + """IRC client implementation.""" + + def __init__(self): + self.socket: Optional[socket.socket] = None + self.connected = False + self.nickname = "" + self.username = "" + self.realname = "" + self.host = "" + self.port = 6667 + self.use_ssl = False + self.channels: Dict[str, bool] = {} + self.channel_topics: Dict[str, str] = {} + self.channel_users: Dict[str, List[str]] = {} + self.channel_list: List[Dict[str, str]] = [] # List of available channels + self.message_handlers: Dict[str, List[Callable]] = {} + self._receive_thread: Optional[threading.Thread] = None + self.auto_reconnect = True + self.reconnect_delay = 5 # seconds + self.max_reconnect_delay = 300 # 5 minutes max + + # SASL/CAP negotiation + self.sasl_username = "" + self.sasl_password = "" + self.cap_negotiating = False + self.cap_acknowledged: List[str] = [] + self.registration_complete = False + + def connect(self, host: str, port: int = 6667, use_ssl: bool = False) -> bool: + """Connect to IRC server.""" + try: + logger.info(f"Attempting to connect to {host}:{port} (SSL: {use_ssl})") + self.host = host + self.port = port + self.use_ssl = use_ssl + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + logger.debug("Socket created") + + if use_ssl: + context = ssl.create_default_context() + self.socket = context.wrap_socket(self.socket, server_hostname=host) + logger.debug("SSL context wrapped") + + self.socket.connect((host, port)) + self.connected = True + logger.info(f"Successfully connected to {host}:{port}") + + self._receive_thread = threading.Thread(target=self._receive_messages) + self._receive_thread.daemon = True + self._receive_thread.start() + logger.debug("Receive thread started") + + return True + + except Exception as e: + logger.error(f"Connection failed: {e}", exc_info=True) + self.emit_event('error', f"Connection failed: {e}") + return False + + def disconnect(self): + """Disconnect from IRC server.""" + if self.connected and self.socket: + self.send_raw("QUIT :StormIRC shutting down") + self.socket.close() + self.connected = False + + def send_raw(self, message: str): + """Send raw IRC message.""" + if not self.connected or not self.socket: + logger.warning(f"Cannot send message - not connected: {message}") + return False + + try: + # Don't log passwords + if 'PASS' in message or 'AUTHENTICATE' in message: + logger.debug(">>> [authentication message redacted]") + else: + logger.debug(f">>> {message}") + encoded_msg = (message + '\r\n').encode('utf-8') + self.socket.send(encoded_msg) + return True + except Exception as e: + logger.error(f"Send failed: {e}", exc_info=True) + self.emit_event('error', f"Send failed: {e}") + return False + + def _receive_messages(self): + """Receive messages from server (runs in thread).""" + logger.debug("Receive thread running") + buffer = "" + + while self.connected and self.socket: + try: + data = self.socket.recv(4096).decode('utf-8', errors='ignore') + if not data: + logger.warning("Received empty data, connection closed by server") + break + + buffer += data + + while '\r\n' in buffer: + line, buffer = buffer.split('\r\n', 1) + if line: + self._handle_message(line) + + except Exception as e: + if self.connected: + logger.error(f"Receive error: {e}", exc_info=True) + self.emit_event('error', f"Receive error: {e}") + break + + was_connected = self.connected + self.connected = False + logger.info("Receive thread ending, was_connected: %s", was_connected) + + if was_connected: + self.emit_event('disconnected', "Connection lost") + + def _handle_message(self, raw_message: str): + """Parse and handle IRC message.""" + logger.debug(f"<<< {raw_message}") + message = self._parse_message(raw_message) + + if message.command == "PING": + self.send_raw(f"PONG :{message.params[0]}") + return + + # Handle CAP negotiation + if message.command == "CAP": + self._handle_cap(message) + return + + # Handle AUTHENTICATE for SASL + if message.command == "AUTHENTICATE": + self._handle_authenticate(message) + return + + # Handle 903 (SASL success) + if message.command == "903": + self._handle_sasl_success() + return + + # Handle 904/905 (SASL failure) + if message.command in ["904", "905"]: + self._handle_sasl_failure(message) + return + + # Handle 353 (NAMES reply) + if message.command == "353": + self._handle_names_reply(message) + + # Handle 366 (End of NAMES) + if message.command == "366": + self._handle_names_end(message) + + # Handle JOIN to track users + if message.command == "JOIN": + self._handle_user_join(message) + + # Handle PART to track users + if message.command == "PART": + self._handle_user_part(message) + + # Handle QUIT to track users + if message.command == "QUIT": + self._handle_user_quit(message) + + # Handle NICK to track nick changes + if message.command == "NICK": + self._handle_nick_change(message) + + # Handle 321 (List start) + if message.command == "321": + self._handle_list_start(message) + + # Handle 322 (List item) + if message.command == "322": + self._handle_list_item(message) + + # Handle 323 (List end) + if message.command == "323": + self._handle_list_end(message) + + # Handle 331 (No topic set) + if message.command == "331": + self._handle_no_topic(message) + + # Handle 332 (Topic) + if message.command == "332": + self._handle_topic(message) + + # Handle 333 (Topic info - who set it and when) + if message.command == "333": + self._handle_topic_info(message) + + # Handle TOPIC command + if message.command == "TOPIC": + self._handle_topic_change(message) + + self.emit_event('raw_message', raw_message) + self.emit_event(message.command.lower(), message) + + def _parse_message(self, raw: str) -> IRCMessage: + """Parse raw IRC message.""" + message = IRCMessage() + + if raw.startswith(':'): + prefix_end = raw.find(' ') + message.prefix = raw[1:prefix_end] + raw = raw[prefix_end + 1:] + + parts = raw.split(' ') + message.command = parts[0].upper() + + params = parts[1:] + for i, param in enumerate(params): + if param.startswith(':'): + message.params.append(' '.join(params[i:])[1:]) + break + message.params.append(param) + + return message + + def register(self, nickname: str, username: str, realname: str, sasl_username: str = "", sasl_password: str = ""): + """Register with IRC server.""" + logger.info(f"Registering as {nickname} (user: {username}, SASL: {bool(sasl_username)})") + self.nickname = nickname + self.username = username + self.realname = realname + self.sasl_username = sasl_username + self.sasl_password = sasl_password + + # If SASL credentials provided, start CAP negotiation + if sasl_username and sasl_password: + logger.debug("Starting CAP negotiation for SASL") + self.cap_negotiating = True + self.send_raw("CAP LS 302") + + self.send_raw(f"NICK {nickname}") + self.send_raw(f"USER {username} 0 * :{realname}") + + def join_channel(self, channel: str, key: str = ""): + """Join a channel.""" + if key: + self.send_raw(f"JOIN {channel} {key}") + else: + self.send_raw(f"JOIN {channel}") + self.channels[channel] = True + + def part_channel(self, channel: str, message: str = ""): + """Leave a channel.""" + if message: + self.send_raw(f"PART {channel} :{message}") + else: + self.send_raw(f"PART {channel}") + if channel in self.channels: + del self.channels[channel] + + def send_message(self, target: str, message: str): + """Send message to channel or user.""" + logger.info(f"send_message: target={target}, message={message}") + self.send_raw(f"PRIVMSG {target} :{message}") + + def change_nick(self, new_nick: str): + """Change nickname.""" + self.send_raw(f"NICK {new_nick}") + self.nickname = new_nick + + def get_topic(self, channel: str): + """Request channel topic.""" + self.send_raw(f"TOPIC {channel}") + + def set_topic(self, channel: str, topic: str): + """Set channel topic.""" + self.send_raw(f"TOPIC {channel} :{topic}") + + def whois(self, nickname: str): + """Request WHOIS information for a user.""" + self.send_raw(f"WHOIS {nickname}") + + def add_handler(self, event: str, handler: Callable): + """Add event handler.""" + if event not in self.message_handlers: + self.message_handlers[event] = [] + self.message_handlers[event].append(handler) + + def remove_handler(self, event: str, handler: Callable): + """Remove event handler.""" + if event in self.message_handlers: + self.message_handlers[event].remove(handler) + + def emit_event(self, event: str, data): + """Emit event to handlers.""" + logger.debug(f"Emitting event '{event}' (handlers registered: {len(self.message_handlers.get(event, []))})") + if event in self.message_handlers: + for handler in self.message_handlers[event]: + try: + handler(data) + except Exception as e: + logger.error(f"Handler error for event '{event}': {e}", exc_info=True) + + def _handle_cap(self, message: IRCMessage): + """Handle CAP responses.""" + if len(message.params) < 2: + return + + subcommand = message.params[1] + + # CAP LS - list available capabilities + if subcommand == "LS": + if len(message.params) >= 3: + available_caps = message.params[2].split() + # Request SASL if available + if "sasl" in available_caps: + self.send_raw("CAP REQ :sasl") + else: + # No SASL available, end negotiation + self.send_raw("CAP END") + self.cap_negotiating = False + + # CAP ACK - capability acknowledged + elif subcommand == "ACK": + if len(message.params) >= 3: + acked_caps = message.params[2].split() + self.cap_acknowledged.extend(acked_caps) + + # If SASL was acknowledged, start authentication + if "sasl" in acked_caps: + self.send_raw("AUTHENTICATE PLAIN") + + # CAP NAK - capability not available + elif subcommand == "NAK": + # End CAP negotiation if request was denied + self.send_raw("CAP END") + self.cap_negotiating = False + self.emit_event('sasl_failed', "SASL capability denied") + + def _handle_authenticate(self, message: IRCMessage): + """Handle AUTHENTICATE response.""" + if len(message.params) < 1: + return + + # Server sends "+" to indicate ready for credentials + if message.params[0] == "+": + # Send SASL PLAIN credentials + # Format: \0username\0password + auth_string = f"\0{self.sasl_username}\0{self.sasl_password}" + auth_base64 = base64.b64encode(auth_string.encode('utf-8')).decode('ascii') + + # Split into 400-byte chunks if needed + if len(auth_base64) > 400: + # Send in chunks + for i in range(0, len(auth_base64), 400): + chunk = auth_base64[i:i+400] + self.send_raw(f"AUTHENTICATE {chunk}") + # Send "+" to indicate end + if len(auth_base64) % 400 == 0: + self.send_raw("AUTHENTICATE +") + else: + self.send_raw(f"AUTHENTICATE {auth_base64}") + + def _handle_sasl_success(self): + """Handle SASL authentication success (903).""" + self.send_raw("CAP END") + self.cap_negotiating = False + self.emit_event('sasl_success', "SASL authentication successful") + + def _handle_sasl_failure(self, message: IRCMessage): + """Handle SASL authentication failure (904/905).""" + self.send_raw("CAP END") + self.cap_negotiating = False + error_msg = message.params[-1] if message.params else "SASL authentication failed" + self.emit_event('sasl_failed', error_msg) + + def _handle_names_reply(self, message: IRCMessage): + """Handle 353 NAMES reply - list of users in channel.""" + # Format: :server 353 nickname = #channel :user1 @user2 +user3 + if len(message.params) < 4: + return + + channel = message.params[2] + names_str = message.params[3] + + # Initialize channel user list if not exists + if channel not in self.channel_users: + self.channel_users[channel] = [] + + # Parse user list (may have prefixes like @, +, etc.) + for name in names_str.split(): + # Strip mode prefixes (@, +, %, etc.) but keep the nickname + nick = name.lstrip('@+%~&!') + if nick and nick not in self.channel_users[channel]: + self.channel_users[channel].append(nick) + + logger.debug(f"NAMES for {channel}: {len(self.channel_users[channel])} users") + + def _handle_names_end(self, message: IRCMessage): + """Handle 366 End of NAMES - all users have been listed.""" + # Format: :server 366 nickname #channel :End of /NAMES list. + if len(message.params) < 2: + return + + channel = message.params[1] + logger.info(f"End of NAMES for {channel}, total users: {len(self.channel_users.get(channel, []))}") + + # Emit event so UI can update user list + self.emit_event('names_end', {'channel': channel, 'users': self.channel_users.get(channel, [])}) + + def _handle_user_join(self, message: IRCMessage): + """Handle JOIN message to track users.""" + if not message.prefix or len(message.params) < 1: + return + + nick = message.prefix.split('!')[0] + channel = message.params[0] + + # Add user to channel list + if channel not in self.channel_users: + self.channel_users[channel] = [] + + if nick not in self.channel_users[channel]: + self.channel_users[channel].append(nick) + logger.debug(f"{nick} joined {channel}, now {len(self.channel_users[channel])} users") + + def _handle_user_part(self, message: IRCMessage): + """Handle PART message to track users.""" + if not message.prefix or len(message.params) < 1: + return + + nick = message.prefix.split('!')[0] + channel = message.params[0] + + # Remove user from channel list + if channel in self.channel_users and nick in self.channel_users[channel]: + self.channel_users[channel].remove(nick) + logger.debug(f"{nick} left {channel}, now {len(self.channel_users[channel])} users") + + def _handle_user_quit(self, message: IRCMessage): + """Handle QUIT message to track users leaving all channels.""" + if not message.prefix: + return + + nick = message.prefix.split('!')[0] + + # Remove user from all channels + for channel in self.channel_users: + if nick in self.channel_users[channel]: + self.channel_users[channel].remove(nick) + logger.debug(f"{nick} quit, removed from {channel}") + + def _handle_nick_change(self, message: IRCMessage): + """Handle NICK message to track nickname changes.""" + if not message.prefix or len(message.params) < 1: + return + + old_nick = message.prefix.split('!')[0] + new_nick = message.params[0] + + # Update nickname in all channels + for channel in self.channel_users: + if old_nick in self.channel_users[channel]: + idx = self.channel_users[channel].index(old_nick) + self.channel_users[channel][idx] = new_nick + logger.debug(f"{old_nick} changed nick to {new_nick} in {channel}") + + # Update our own nickname if it's us + if old_nick == self.nickname: + self.nickname = new_nick + logger.info(f"Our nickname changed to {new_nick}") + + def _handle_list_start(self, message: IRCMessage): + """Handle 321 - Start of LIST.""" + # Format: :server 321 nickname Channel :Users Name + logger.info("Starting channel list") + self.channel_list = [] # Clear previous list + + def _handle_list_item(self, message: IRCMessage): + """Handle 322 - Channel list item.""" + # Format: :server 322 nickname #channel user_count :topic + if len(message.params) < 3: + return + + channel = message.params[1] + user_count = message.params[2] + topic = message.params[3] if len(message.params) > 3 else "" + + self.channel_list.append({ + 'channel': channel, + 'users': user_count, + 'topic': topic + }) + logger.debug(f"LIST item: {channel} ({user_count} users)") + + def _handle_list_end(self, message: IRCMessage): + """Handle 323 - End of LIST.""" + # Format: :server 323 nickname :End of /LIST + logger.info(f"End of LIST, total channels: {len(self.channel_list)}") + self.emit_event('list_end', self.channel_list) + + def _handle_no_topic(self, message: IRCMessage): + """Handle 331 - No topic set.""" + # Format: :server 331 nickname #channel :No topic is set + if len(message.params) < 2: + return + + channel = message.params[1] + self.channel_topics[channel] = "" + logger.info(f"No topic set for {channel}") + self.emit_event('topic', {'channel': channel, 'topic': '', 'setter': None, 'time': None}) + + def _handle_topic(self, message: IRCMessage): + """Handle 332 - Topic.""" + # Format: :server 332 nickname #channel :topic text + if len(message.params) < 3: + return + + channel = message.params[1] + topic = message.params[2] + self.channel_topics[channel] = topic + logger.info(f"Topic for {channel}: {topic}") + self.emit_event('topic', {'channel': channel, 'topic': topic, 'setter': None, 'time': None}) + + def _handle_topic_info(self, message: IRCMessage): + """Handle 333 - Topic info (who set it and when).""" + # Format: :server 333 nickname #channel setter timestamp + if len(message.params) < 4: + return + + channel = message.params[1] + setter = message.params[2] + timestamp = message.params[3] + logger.debug(f"Topic for {channel} set by {setter} at {timestamp}") + # We already emitted the topic in 332, so just log this + + def _handle_topic_change(self, message: IRCMessage): + """Handle TOPIC command (someone changed the topic).""" + # Format: :nick!user@host TOPIC #channel :new topic + if not message.prefix or len(message.params) < 1: + return + + setter = message.prefix.split('!')[0] + channel = message.params[0] + topic = message.params[1] if len(message.params) > 1 else "" + + self.channel_topics[channel] = topic + logger.info(f"{setter} changed topic for {channel}: {topic}") + self.emit_event('topic', {'channel': channel, 'topic': topic, 'setter': setter, 'time': None}) diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..3e21c87 --- /dev/null +++ b/src/ui/__init__.py @@ -0,0 +1 @@ +"""UI modules for StormIRC.""" \ No newline at end of file diff --git a/src/ui/__pycache__/__init__.cpython-313.pyc b/src/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4cfc3a9 Binary files /dev/null and b/src/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/ui/__pycache__/accessible_tree.cpython-313.pyc b/src/ui/__pycache__/accessible_tree.cpython-313.pyc new file mode 100644 index 0000000..84ef032 Binary files /dev/null and b/src/ui/__pycache__/accessible_tree.cpython-313.pyc differ diff --git a/src/ui/__pycache__/autocomplete_textedit.cpython-313.pyc b/src/ui/__pycache__/autocomplete_textedit.cpython-313.pyc new file mode 100644 index 0000000..5a599b2 Binary files /dev/null and b/src/ui/__pycache__/autocomplete_textedit.cpython-313.pyc differ diff --git a/src/ui/__pycache__/logger.cpython-313.pyc b/src/ui/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..e00f311 Binary files /dev/null and b/src/ui/__pycache__/logger.cpython-313.pyc differ diff --git a/src/ui/__pycache__/main_window.cpython-313.pyc b/src/ui/__pycache__/main_window.cpython-313.pyc new file mode 100644 index 0000000..ff5c054 Binary files /dev/null and b/src/ui/__pycache__/main_window.cpython-313.pyc differ diff --git a/src/ui/__pycache__/pm_window.cpython-313.pyc b/src/ui/__pycache__/pm_window.cpython-313.pyc new file mode 100644 index 0000000..2e74f7b Binary files /dev/null and b/src/ui/__pycache__/pm_window.cpython-313.pyc differ diff --git a/src/ui/__pycache__/settings_dialog.cpython-313.pyc b/src/ui/__pycache__/settings_dialog.cpython-313.pyc new file mode 100644 index 0000000..ade1a3e Binary files /dev/null and b/src/ui/__pycache__/settings_dialog.cpython-313.pyc differ diff --git a/src/ui/__pycache__/sound.cpython-313.pyc b/src/ui/__pycache__/sound.cpython-313.pyc new file mode 100644 index 0000000..967a601 Binary files /dev/null and b/src/ui/__pycache__/sound.cpython-313.pyc differ diff --git a/src/ui/__pycache__/speech.cpython-313.pyc b/src/ui/__pycache__/speech.cpython-313.pyc new file mode 100644 index 0000000..412b95b Binary files /dev/null and b/src/ui/__pycache__/speech.cpython-313.pyc differ diff --git a/src/ui/__pycache__/ui_utils.cpython-313.pyc b/src/ui/__pycache__/ui_utils.cpython-313.pyc new file mode 100644 index 0000000..3b19071 Binary files /dev/null and b/src/ui/__pycache__/ui_utils.cpython-313.pyc differ diff --git a/src/ui/accessible_tree.py b/src/ui/accessible_tree.py new file mode 100644 index 0000000..f3a7f96 --- /dev/null +++ b/src/ui/accessible_tree.py @@ -0,0 +1,48 @@ +""" +Accessible tree widget - simplified version from bifrost for IRC use +""" + +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QKeyEvent + + +class AccessibleTreeWidget(QTreeWidget): + """Tree widget with enhanced accessibility""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setup_accessibility() + + def setup_accessibility(self): + """Set up accessibility features""" + self.setFocusPolicy(Qt.StrongFocus) + self.itemExpanded.connect(self.on_item_expanded) + self.itemCollapsed.connect(self.on_item_collapsed) + + def on_item_expanded(self, item: QTreeWidgetItem): + """Handle item expansion""" + # Make child items accessible + self.update_child_accessibility(item, True) + + def on_item_collapsed(self, item: QTreeWidgetItem): + """Handle item collapse""" + # Hide child items from screen readers + self.update_child_accessibility(item, False) + + def update_child_accessibility(self, item: QTreeWidgetItem, visible: bool): + """Update accessibility properties of child items""" + for i in range(item.childCount()): + child = item.child(i) + if visible: + # Make child accessible + child.setFlags(child.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable) + child.setData(0, Qt.AccessibleDescriptionRole, "") + # Recursively handle nested children + self.update_child_accessibility(child, child.isExpanded()) + else: + # Hide from screen readers + child.setData(0, Qt.AccessibleDescriptionRole, "hidden") + child.setFlags((child.flags() | Qt.ItemIsEnabled) & ~Qt.ItemIsSelectable) + # Hide all nested children too + self.update_child_accessibility(child, False) diff --git a/src/ui/autocomplete_textedit.py b/src/ui/autocomplete_textedit.py new file mode 100644 index 0000000..b38b5f2 --- /dev/null +++ b/src/ui/autocomplete_textedit.py @@ -0,0 +1,288 @@ +"""Autocomplete text edit widget for IRC channel and nickname completion.""" + +import logging +from PySide6.QtWidgets import QPlainTextEdit, QCompleter, QListWidget +from PySide6.QtCore import Qt, QStringListModel +from PySide6.QtGui import QKeyEvent, QTextCursor + +logger = logging.getLogger(__name__) + + +class AutocompleteTextEdit(QPlainTextEdit): + """QPlainTextEdit with autocomplete support for channels (#) and nicknames.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.completer = None + self.completion_start = 0 + self.completion_prefix = "" + self.completion_type = None # 'channel' or 'nickname' + + # Data sources for completion + self.channels = [] # List of available channels + self.nicknames = [] # List of nicknames in current channel + + # Setup completer + self.setup_completer() + + def setup_completer(self): + """Initialize the completer.""" + self.completer = QCompleter() + self.completer.setWidget(self) + self.completer.setCaseSensitivity(Qt.CaseInsensitive) + self.completer.setCompletionMode(QCompleter.PopupCompletion) + + # Connect activation signal + self.completer.activated.connect(self.insert_completion) + + def set_channels(self, channels): + """Update the list of available channels.""" + self.channels = sorted(channels) + logger.debug(f"Autocomplete channels updated: {self.channels}") + + def set_nicknames(self, nicknames): + """Update the list of nicknames for the current channel.""" + self.nicknames = sorted(nicknames) + logger.debug(f"Autocomplete nicknames updated: {len(self.nicknames)} users") + + def keyPressEvent(self, event: QKeyEvent): + """Handle key press events for autocomplete.""" + key = event.key() + + # Handle autocomplete navigation when popup is visible + if self.completer and self.completer.popup().isVisible(): + if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]: + self.handle_completer_key(key) + return + elif key == Qt.Key_Escape: + self.hide_completer() + return + + # Handle Enter/Return key - should never insert newline (let parent handle sending) + if key in [Qt.Key_Return, Qt.Key_Enter]: + # Don't handle it here - let the parent's event filter handle it + # This prevents inserting a newline + event.ignore() + return + + # Handle Tab key for smart completion or focus change + if key == Qt.Key_Tab: + full_text = self.toPlainText() + if not full_text.strip(): + # Text box is empty - allow focus change to next control + event.ignore() + return + + # Get the character before cursor to determine behavior + cursor = self.textCursor() + pos = cursor.position() + + if pos > 0: + # Use full_text (not stripped) to get correct character position + char_before = full_text[pos - 1] + + # If last character is alphanumeric or #, try autocomplete + if char_before.isalnum() or char_before == '#': + if self.try_autocomplete(): + return # Completion was triggered + # No completion available, consume the event + return + else: + # Last character is punctuation or whitespace - move to next control + event.ignore() + return + else: + # At beginning, allow focus change + event.ignore() + return + + # Normal key processing + super().keyPressEvent(event) + + # After typing, check if we should update the completer + if self.completer and self.completer.popup().isVisible(): + self.update_completer() + + def handle_completer_key(self, key): + """Handle navigation keys in completer popup.""" + popup = self.completer.popup() + + if key in [Qt.Key_Up, Qt.Key_Down]: + # Let the popup handle up/down navigation + if key == Qt.Key_Up: + current = popup.currentIndex().row() + if current > 0: + popup.setCurrentIndex(popup.model().index(current - 1, 0)) + else: + current = popup.currentIndex().row() + if current < popup.model().rowCount() - 1: + popup.setCurrentIndex(popup.model().index(current + 1, 0)) + + elif key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]: + # Insert the selected completion + self.insert_completion(popup.currentIndex().data()) + + def try_autocomplete(self): + """Try to trigger autocomplete at current cursor position.""" + cursor = self.textCursor() + text = self.toPlainText() + pos = cursor.position() + + if pos == 0: + return False + + # Find the current word being typed (back to last space/newline/tab) + start_pos = pos - 1 + while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']: + start_pos -= 1 + start_pos += 1 + + current_word = text[start_pos:pos] + + if not current_word: + return False + + # Determine what type of completion to show + if current_word.startswith('#'): + # Channel completion + prefix = current_word[1:] # Remove # + return self.show_channel_completer(prefix, start_pos) + else: + # Nickname completion + return self.show_nickname_completer(current_word, start_pos) + + def show_channel_completer(self, prefix, start_pos): + """Show channel completion popup.""" + if not self.channels: + logger.debug("No channels available for completion") + return False + + # Filter channels by prefix + matches = [ch for ch in self.channels if ch.lower().startswith(prefix.lower())] + + if not matches: + logger.debug(f"No channel matches for prefix: {prefix}") + self.hide_completer() + return False + + logger.debug(f"Found {len(matches)} channel matches for '{prefix}': {matches}") + + # Update completer model + model = QStringListModel(matches) + self.completer.setModel(model) + + # Store completion info + self.completion_start = start_pos + self.completion_prefix = prefix + self.completion_type = 'channel' + + # Show popup + cursor = self.textCursor() + cursor.setPosition(start_pos) + rect = self.cursorRect(cursor) + rect.setWidth(self.completer.popup().sizeHintForColumn(0) + + self.completer.popup().verticalScrollBar().sizeHint().width()) + self.completer.complete(rect) + + return True + + def show_nickname_completer(self, prefix, start_pos): + """Show nickname completion popup.""" + if not self.nicknames: + logger.debug("No nicknames available for completion") + return False + + # Filter nicknames by prefix + matches = [nick for nick in self.nicknames if nick.lower().startswith(prefix.lower())] + + if not matches: + logger.debug(f"No nickname matches for prefix: {prefix}") + self.hide_completer() + return False + + logger.debug(f"Found {len(matches)} nickname matches for '{prefix}': {matches[:5]}...") + + # Update completer model + model = QStringListModel(matches) + self.completer.setModel(model) + + # Store completion info + self.completion_start = start_pos + self.completion_prefix = prefix + self.completion_type = 'nickname' + + # Show popup + cursor = self.textCursor() + cursor.setPosition(start_pos) + rect = self.cursorRect(cursor) + rect.setWidth(self.completer.popup().sizeHintForColumn(0) + + self.completer.popup().verticalScrollBar().sizeHint().width()) + self.completer.complete(rect) + + return True + + def update_completer(self): + """Update completer matches as user types.""" + cursor = self.textCursor() + text = self.toPlainText() + pos = cursor.position() + + # Find current word + start_pos = pos - 1 + while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']: + start_pos -= 1 + start_pos += 1 + + current_word = text[start_pos:pos] + + # Update based on completion type + if self.completion_type == 'channel' and current_word.startswith('#'): + prefix = current_word[1:] + matches = [ch for ch in self.channels if ch.lower().startswith(prefix.lower())] + if matches: + model = QStringListModel(matches) + self.completer.setModel(model) + else: + self.hide_completer() + elif self.completion_type == 'nickname': + matches = [nick for nick in self.nicknames if nick.lower().startswith(current_word.lower())] + if matches: + model = QStringListModel(matches) + self.completer.setModel(model) + else: + self.hide_completer() + else: + self.hide_completer() + + def hide_completer(self): + """Hide the completer popup.""" + if self.completer: + self.completer.popup().hide() + + def insert_completion(self, completion): + """Insert the selected completion.""" + if not completion: + return + + logger.debug(f"Inserting completion: {completion} (type: {self.completion_type})") + + # Get cursor and select the text to replace + cursor = self.textCursor() + cursor.setPosition(self.completion_start) + cursor.setPosition(cursor.position() + len(self.completion_prefix) + (1 if self.completion_type == 'channel' else 0), QTextCursor.KeepAnchor) + + # Insert completion with appropriate formatting + if self.completion_type == 'channel': + cursor.insertText(f"#{completion} ") + elif self.completion_type == 'nickname': + # Check if we're at the beginning of a line (for nickname: format) + line_start = cursor.position() == 0 or self.toPlainText()[self.completion_start - 1] in ['\n'] + if line_start: + cursor.insertText(f"{completion}: ") + else: + cursor.insertText(f"{completion} ") + + # Update cursor position + self.setTextCursor(cursor) + self.hide_completer() diff --git a/src/ui/logger.py b/src/ui/logger.py new file mode 100644 index 0000000..b094dd5 --- /dev/null +++ b/src/ui/logger.py @@ -0,0 +1,151 @@ +"""Message logging for StormIRC.""" + +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class MessageLogger: + """Handles logging IRC messages to disk.""" + + def __init__(self, log_dir: Optional[str] = None): + """Initialize message logger. + + Args: + log_dir: Directory to store logs. Defaults to ~/.config/stormirc/logs/ + """ + if log_dir is None: + log_dir = os.path.expanduser("~/.config/stormirc/logs") + + self.log_dir = Path(log_dir) + self.log_dir.mkdir(parents=True, exist_ok=True) + self.enabled = True + self.log_files = {} # Cache of open log file handles + + def set_enabled(self, enabled: bool): + """Enable or disable logging.""" + self.enabled = enabled + if not enabled: + self._close_all_files() + + def log_message(self, server: str, target: str, message: str, timestamp: Optional[datetime] = None): + """Log a message to disk. + + Args: + server: Server name/host + target: Channel or nickname + message: The message to log + timestamp: Optional timestamp (defaults to now) + """ + if not self.enabled: + return + + if timestamp is None: + timestamp = datetime.now() + + # Sanitize server and target for filenames + safe_server = self._sanitize_filename(server) + safe_target = self._sanitize_filename(target) + + # Create server directory if needed + server_dir = self.log_dir / safe_server + server_dir.mkdir(exist_ok=True) + + # Create log file path: server/target-YYYY-MM-DD.log + date_str = timestamp.strftime("%Y-%m-%d") + log_file = server_dir / f"{safe_target}-{date_str}.log" + + # Format: [HH:MM:SS] message + time_str = timestamp.strftime("%H:%M:%S") + log_line = f"[{time_str}] {message}\n" + + # Append to log file + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(log_line) + except Exception as e: + logger.error(f"Error writing to log file: {e}", exc_info=True) + + def read_recent_messages(self, server: str, target: str, limit: int = 100): + """Read recent messages from log. + + Args: + server: Server name/host + target: Channel or nickname + limit: Maximum number of messages to return + + Returns: + List of message strings (without timestamps) + """ + safe_server = self._sanitize_filename(server) + safe_target = self._sanitize_filename(target) + + server_dir = self.log_dir / safe_server + if not server_dir.exists(): + return [] + + # Get today's log file + date_str = datetime.now().strftime("%Y-%m-%d") + log_file = server_dir / f"{safe_target}-{date_str}.log" + + if not log_file.exists(): + # Try yesterday's file if today's doesn't exist + yesterday = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + yesterday = yesterday.replace(day=yesterday.day - 1) + date_str = yesterday.strftime("%Y-%m-%d") + log_file = server_dir / f"{safe_target}-{date_str}.log" + + if not log_file.exists(): + return [] + + try: + with open(log_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + # Return last 'limit' lines + return [line.rstrip('\n') for line in lines[-limit:]] + except Exception as e: + logger.error(f"Error reading log file: {e}", exc_info=True) + return [] + + def _sanitize_filename(self, name: str) -> str: + """Sanitize a string for use as a filename. + + Args: + name: The string to sanitize + + Returns: + Sanitized filename-safe string + """ + # Remove or replace characters that aren't safe for filenames + safe = name.replace('/', '_').replace('\\', '_') + safe = safe.replace(':', '_').replace('*', '_') + safe = safe.replace('?', '_').replace('"', '_') + safe = safe.replace('<', '_').replace('>', '_') + safe = safe.replace('|', '_') + + # Remove # prefix from channels + if safe.startswith('#'): + safe = safe[1:] + + return safe + + def _close_all_files(self): + """Close all cached file handles.""" + for f in self.log_files.values(): + try: + f.close() + except Exception: + pass + self.log_files.clear() + + def get_log_directory(self) -> str: + """Get the log directory path. + + Returns: + Path to log directory + """ + return str(self.log_dir) diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..460b64f --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,1392 @@ +"""Main window for StormIRC - Qt version.""" + +import logging +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QTreeWidget, QTreeWidgetItem, QTextEdit, QLineEdit, QPushButton, + QLabel, QListWidget, QStatusBar, QMenuBar, QMenu, QToolBar, + QDialog, QFormLayout, QSpinBox, QCheckBox, QDialogButtonBox, + QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, + QPlainTextEdit, QSlider +) +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtGui import QAction, QKeySequence, QAccessible +from PySide6.QtCore import QObject +from src.irc.client import IRCClient, IRCMessage +from src.config.settings import ConfigManager, ServerConfig, is_valid_text +from src.ui.accessible_tree import AccessibleTreeWidget +from src.ui.autocomplete_textedit import AutocompleteTextEdit +from src.ui.settings_dialog import SettingsDialog +from src.ui.pm_window import PMWindow +from src.ui.sound import SoundPlayer +from src.ui.speech import SpeechManager +from src.ui.ui_utils import ( + format_message_with_timestamp, + apply_speech_settings, + DEFAULT_WINDOW_WIDTH, + DEFAULT_WINDOW_HEIGHT, + DEFAULT_TOPIC_BAR_HEIGHT, + MESSAGE_ENTRY_MAX_HEIGHT +) + +logger = logging.getLogger(__name__) + + +class MainWindow(QMainWindow): + """Main application window.""" + + # Define Qt signals for cross-thread communication + channel_joined = Signal(str) # Signal for when we join a channel + message_received = Signal(str, str) # Signal for (channel, message) + user_list_updated = Signal(str, list) # Signal for (channel, user_list) + channel_list_ready = Signal(list) # Signal for channel list + private_message_received = Signal(str, str) # Signal for (sender, text) + + def __init__(self): + super().__init__() + + self.config_manager = ConfigManager() + self.irc_client = IRCClient() + self.current_target = "" + self.chat_buffers = {} + self.connection_info = None + self.pm_windows = {} # Dictionary of nickname -> PMWindow + self.sound_player = SoundPlayer(self.config_manager) # Initialize sound player with config + + # Initialize speech manager + self.speech_manager = SpeechManager() + self.configure_speech() + + self.setWindowTitle("StormIRC") + ui_config = self.config_manager.get_ui_config() + self.resize(ui_config.window_width, ui_config.window_height) + + self.setup_ui() + self.setup_irc_handlers() + self.setup_signal_connections() + self.populate_initial_servers() + + def setup_signal_connections(self): + """Connect Qt signals to slots.""" + self.channel_joined.connect(self.on_channel_joined_ui) + self.message_received.connect(self.on_message_received_ui) + self.user_list_updated.connect(self.on_user_list_updated_ui) + self.channel_list_ready.connect(self.on_channel_list_ready_ui) + self.private_message_received.connect(self.handle_private_message) + + def eventFilter(self, obj, event): + """Event filter to handle Enter key in message entry.""" + from PySide6.QtCore import QEvent + from PySide6.QtGui import QKeyEvent + + if obj == self.message_entry and event.type() == QEvent.KeyPress: + key_event = event + # Enter without Shift sends message, Shift+Enter inserts newline + if key_event.key() in (Qt.Key_Return, Qt.Key_Enter): + if not (key_event.modifiers() & Qt.ShiftModifier): + self.on_message_send() + return True # Event handled + return super().eventFilter(obj, event) + + def keyPressEvent(self, event): + """Handle key press events - Ctrl stops all speech.""" + if event.key() == Qt.Key_Control: + self.speech_manager.stop() + super().keyPressEvent(event) + + def closeEvent(self, event): + """Handle window close event - clean up resources.""" + logger.info("Application closing, cleaning up...") + + # Disconnect from IRC if connected + if self.irc_client and self.irc_client.connected: + try: + self.irc_client.disconnect() + except Exception as e: + logger.error(f"Error disconnecting IRC client: {e}") + + # Close all PM windows + for pm_window in list(self.pm_windows.values()): + try: + pm_window.close() + except Exception as e: + logger.error(f"Error closing PM window: {e}") + + # Close speech manager + try: + self.speech_manager.close() + except Exception as e: + logger.error(f"Error closing speech manager: {e}") + + # Accept the close event + event.accept() + + # Quit the application + from PySide6.QtWidgets import QApplication + QApplication.quit() + + def configure_speech(self): + """Configure speech manager from settings.""" + accessibility = self.config_manager.get_accessibility_config() + + logger.info(f"Configuring speech: enabled={accessibility.speech_enabled}") + + # Enable/disable speech globally + self.speech_manager.set_enabled(accessibility.speech_enabled) + + if accessibility.speech_enabled: + # Set global speech parameters + # CRITICAL: Set output module BEFORE voice, because changing module resets voice + logger.debug(f"Setting speech parameters: rate={accessibility.speech_rate}, pitch={accessibility.speech_pitch}, volume={accessibility.speech_volume}, module={repr(accessibility.speech_output_module)}, voice={repr(accessibility.speech_voice)}") + + # Always set module and voice to update defaults (even if empty string) + # Empty string means "use speech-dispatcher system default" + # Set output module first + if accessibility.speech_output_module: + logger.debug(f"Setting output module: {accessibility.speech_output_module}") + self.speech_manager.set_output_module(accessibility.speech_output_module) + else: + # Clear the default module (use system default) + self.speech_manager.default_module = "" + + # Set voice after module + if accessibility.speech_voice: + logger.debug(f"Setting voice: {accessibility.speech_voice}") + self.speech_manager.set_voice(accessibility.speech_voice) + else: + # Clear the default voice (use system default) + self.speech_manager.default_voice = "" + + # Set rate, pitch, and volume (always set, even if 0) + self.speech_manager.set_rate(accessibility.speech_rate) + self.speech_manager.set_pitch(accessibility.speech_pitch) + self.speech_manager.set_volume(accessibility.speech_volume) + + def speak_for_channel(self, target: str, text: str): + """Speak text with channel/PM-specific settings.""" + accessibility = self.config_manager.get_accessibility_config() + + logger.debug(f"speak_for_channel called: target={target}, speech_enabled={accessibility.speech_enabled}, auto_read={accessibility.auto_read_messages}") + + # Check if speech is enabled and auto-read is on + if not accessibility.speech_enabled: + logger.debug("Speech is disabled, not speaking") + return + + if not accessibility.auto_read_messages: + logger.debug("Auto-read messages is disabled, not speaking") + return + + # Validate text has meaningful content + if not is_valid_text(text): + logger.debug(f"Text has no valid content: {text[:50]}") + return + + logger.debug(f"Speech manager is_enabled: {self.speech_manager.is_enabled()}") + + # Get channel-specific settings + if self.connection_info: + server_name = self.connection_info.get('server_name', '') + channel_settings = self.config_manager.get_channel_speech_settings(server_name, target) + + # Apply channel-specific settings using shared utility + apply_speech_settings(self.speech_manager, channel_settings, target) + + # Prepare text to speak + speech_text = text + + # Add timestamp to spoken text if UI timestamps are enabled AND read timestamps is on + ui_config = self.config_manager.get_ui_config() + if ui_config.show_timestamps and accessibility.speech_read_timestamps: + # Include timestamp in speech + from datetime import datetime + timestamp = datetime.now().strftime(ui_config.timestamp_format) + speech_text = f"{text} {timestamp}" + # Otherwise just speak the text without timestamp (but it's still visible in display) + + # Speak the text + logger.info(f"Calling speech_manager.speak with text: {speech_text[:50]}...") + self.speech_manager.speak(speech_text, window_id=target, priority="message", interrupt=False) + + def setup_ui(self): + """Set up the user interface.""" + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QVBoxLayout(central_widget) + + # Create menu bar + self.create_menu_bar() + + # Create toolbar + self.create_toolbar() + + # Main splitter (server list | chat area) + main_splitter = QSplitter(Qt.Horizontal) + + # Left panel - Server/Channel list + self.server_list = AccessibleTreeWidget() + self.server_list.setHeaderLabels(["Servers and Channels"]) + self.server_list.setAccessibleName("Server and channel list") + self.server_list.setAccessibleDescription("List of configured servers and active channels. Press Enter to connect to a server or switch to a channel.") + self.server_list.itemActivated.connect(self.on_item_activated) + self.server_list.setMinimumWidth(200) + self.server_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.server_list.customContextMenuRequested.connect(self.show_channel_context_menu) + main_splitter.addWidget(self.server_list) + + # Right panel - Chat area + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + + # Chat label + self.chat_label = QLabel("Welcome to StormIRC") + self.chat_label.setAccessibleName("Current channel or query") + right_layout.addWidget(self.chat_label) + + # Topic label + self.topic_label = QLabel() + self.topic_label.setWordWrap(True) + self.topic_label.setAccessibleName("Channel topic") + self.topic_label.hide() + right_layout.addWidget(self.topic_label) + + # Chat splitter (messages | user list) + chat_splitter = QSplitter(Qt.Horizontal) + + # Chat view + self.chat_view = QTextEdit() + self.chat_view.setReadOnly(True) + # Enable cursor for screen reader navigation + self.chat_view.setTextInteractionFlags( + Qt.TextSelectableByMouse | + Qt.TextSelectableByKeyboard | + Qt.LinksAccessibleByMouse | + Qt.LinksAccessibleByKeyboard + ) + self.chat_view.setFocusPolicy(Qt.StrongFocus) + self.chat_view.setAccessibleName("Chat messages") + self.chat_view.setAccessibleDescription("Chat messages are displayed here. Use arrow keys to navigate through messages.") + chat_splitter.addWidget(self.chat_view) + + # User list + self.user_list = QListWidget() + self.user_list.setAccessibleName("User list") + self.user_list.setAccessibleDescription("List of users in the current channel. Press Enter or double-click to open private message.") + self.user_list.setMaximumWidth(150) + self.user_list.itemActivated.connect(self.on_user_activated) + chat_splitter.addWidget(self.user_list) + + chat_splitter.setStretchFactor(0, 3) + chat_splitter.setStretchFactor(1, 1) + right_layout.addWidget(chat_splitter) + + # Input area + input_layout = QHBoxLayout() + # Use AutocompleteTextEdit for channel and nickname completion + self.message_entry = AutocompleteTextEdit() + self.message_entry.setPlaceholderText("Type your message here... (Tab to complete #channels or nicknames)") + self.message_entry.setAccessibleName("Message input") + self.message_entry.setAccessibleDescription("Type your message and press Enter to send. Press Tab to autocomplete channels starting with # or nicknames.") + self.message_entry.setMaximumHeight(MESSAGE_ENTRY_MAX_HEIGHT) + self.message_entry.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.message_entry.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # Install event filter to handle Enter key + self.message_entry.installEventFilter(self) + logger.debug("Installed event filter on message_entry for Enter key handling") + input_layout.addWidget(self.message_entry) + + send_button = QPushButton("Send") + logger.debug("Connecting send_button.clicked to on_message_send") + send_button.clicked.connect(self.on_message_send) + input_layout.addWidget(send_button) + + right_layout.addLayout(input_layout) + + main_splitter.addWidget(right_widget) + main_splitter.setStretchFactor(0, 1) + main_splitter.setStretchFactor(1, 3) + + main_layout.addWidget(main_splitter) + + # Status bar with accessible label + self.status_bar = QStatusBar() + self.status_label = QLabel() + self.status_label.setFocusPolicy(Qt.StrongFocus) # Make it focusable + self.status_label.setAccessibleName("Connection status") + self.set_status("Disconnected") + self.status_bar.addPermanentWidget(self.status_label) + self.setStatusBar(self.status_bar) + + # Give focus to server list initially + self.server_list.setFocus() + + def create_menu_bar(self): + """Create menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + self.connect_action = QAction("&Connect", self) + self.connect_action.setShortcut("Ctrl+N") + self.connect_action.triggered.connect(self.on_connect_clicked) + file_menu.addAction(self.connect_action) + + self.join_action = QAction("&Join Channel", self) + self.join_action.setShortcut("Ctrl+J") + self.join_action.triggered.connect(self.on_join_channel_clicked) + self.join_action.setEnabled(False) # Disabled until connected + file_menu.addAction(self.join_action) + + file_menu.addSeparator() + + quit_action = QAction("&Quit", self) + quit_action.setShortcut("Ctrl+Q") + quit_action.triggered.connect(self.close) + file_menu.addAction(quit_action) + + # Settings menu + settings_menu = menubar.addMenu("&Settings") + + preferences_action = QAction("&Preferences", self) + preferences_action.triggered.connect(self.on_settings_clicked) + settings_menu.addAction(preferences_action) + + def create_toolbar(self): + """Create toolbar.""" + toolbar = QToolBar("Main Toolbar") + toolbar.setAccessibleName("Main toolbar") + self.addToolBar(toolbar) + + # Connect button + toolbar.addAction(self.connect_action) + + # Join button + toolbar.addAction(self.join_action) + + # Settings button + settings_action = QAction("Settings", self) + settings_action.triggered.connect(self.on_settings_clicked) + toolbar.addAction(settings_action) + + def set_status(self, status_text): + """Set status bar text and update accessible properties.""" + self.status_label.setText(status_text) + # Only set accessible name once at init to avoid repeated property updates + # The text itself is already accessible via the label + + def setup_irc_handlers(self): + """Set up IRC event handlers.""" + self.irc_client.add_handler('privmsg', self.on_privmsg) + self.irc_client.add_handler('join', self.on_join) + self.irc_client.add_handler('part', self.on_part) + self.irc_client.add_handler('quit', self.on_quit) + self.irc_client.add_handler('nick', self.on_nick) + self.irc_client.add_handler('names_end', self.on_names_end) + self.irc_client.add_handler('list_end', self.on_list_end) + self.irc_client.add_handler('topic', self.on_topic) + self.irc_client.add_handler('001', self.on_welcome) + self.irc_client.add_handler('disconnected', self.on_disconnected) + self.irc_client.add_handler('error', self.on_error) + self.irc_client.add_handler('sasl_success', self.on_sasl_success) + self.irc_client.add_handler('sasl_failed', self.on_sasl_failed) + + def populate_initial_servers(self): + """Populate server list with configured servers.""" + logger.info(f"Populating {len(self.config_manager.config.servers)} servers") + self.server_list.clear() + for server in self.config_manager.config.servers: + logger.debug(f"Adding server: {server.name}") + item = QTreeWidgetItem([server.name]) + item.setData(0, Qt.UserRole, {"type": "configured_server", "server": server}) + item.setData(0, Qt.AccessibleTextRole, f"{server.name} server") + self.server_list.addTopLevelItem(item) + + # Select first server if available + if self.server_list.topLevelItemCount() > 0: + logger.debug(f"Setting current item to first server") + self.server_list.setCurrentItem(self.server_list.topLevelItem(0)) + + def on_item_activated(self, item, column): + """Handle server/channel activation.""" + data = item.data(0, Qt.UserRole) + if not data: + return + + item_type = data.get("type") + + if item_type == "configured_server": + # Connect to configured server + server = data.get("server") + if server: + self.connect_to_server(server) + + elif item_type == "channel" or item_type == "query": + # Switch to channel/query + channel_name = data.get("name") + if channel_name: + self.current_target = channel_name + self.chat_label.setText(f"{'Channel' if item_type == 'channel' else 'Query'}: {channel_name}") + self.load_buffer(channel_name) + + def connect_to_server(self, server_config): + """Connect to an IRC server.""" + self.connection_info = { + 'server_config': server_config, + 'server_name': server_config.name + } + + if self.irc_client.connect(server_config.host, server_config.port, server_config.use_ssl): + self.irc_client.register( + server_config.nickname, + server_config.username, + server_config.realname, + server_config.sasl_username, + server_config.sasl_password + ) + self.set_status(f"Connecting to {server_config.host}:{server_config.port}...") + + # Clear configured servers and show connected server + self.server_list.clear() + server_item = QTreeWidgetItem([f"{server_config.host}:{server_config.port}"]) + server_item.setData(0, Qt.UserRole, {"type": "server"}) + self.server_list.addTopLevelItem(server_item) + + def on_connect_clicked(self): + """Handle connect menu action.""" + dialog = ConnectDialog(self) + if dialog.exec() == QDialog.Accepted: + server_config = dialog.get_server_config() + + # Check if this server already exists in config + existing_server = self.config_manager.get_server(server_config.name) + if not existing_server: + # Add new server to config so it persists across launches + self.config_manager.add_server(server_config) + logger.info(f"Saved new server to config: {server_config.name}") + + self.connect_to_server(server_config) + + def on_settings_clicked(self): + """Handle settings menu action.""" + logger.info("Opening settings dialog...") + try: + dialog = SettingsDialog(self, self.config_manager) + logger.info("Settings dialog created successfully") + dialog.settings_changed.connect(self.on_settings_changed) + dialog.exec() + logger.info("Settings dialog closed") + except Exception as e: + logger.error(f"Error opening settings dialog: {e}", exc_info=True) + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical( + self, + "Error", + f"Failed to open settings: {e}" + ) + + def on_settings_changed(self): + """Handle settings changes.""" + # Reload configuration + self.config_manager.load_config() + + # Apply UI settings + ui_config = self.config_manager.get_ui_config() + self.resize(ui_config.window_width, ui_config.window_height) + + # Update font size if changed + accessibility_config = self.config_manager.get_accessibility_config() + font = self.chat_display.font() + font.setPointSize(accessibility_config.font_size) + self.chat_display.setFont(font) + + logger.info("Settings updated") + self.set_status("Settings updated") + + # Reconfigure speech with updated settings + self.configure_speech() + + def show_channel_context_menu(self, position): + """Show context menu for channel list.""" + item = self.server_list.itemAt(position) + if not item: + return + + data = item.data(0, Qt.UserRole) + if not data or data.get('type') != 'channel': + return + + channel_name = data.get('name', '') + if not channel_name: + return + + menu = QMenu(self) + + speech_settings_action = menu.addAction("Speech Settings...") + test_voice_action = menu.addAction("Test Voice") + + action = menu.exec(self.server_list.viewport().mapToGlobal(position)) + + if action == speech_settings_action: + self.show_channel_speech_dialog(channel_name) + elif action == test_voice_action: + self.test_channel_voice(channel_name) + + def show_channel_speech_dialog(self, channel_name): + """Show speech settings dialog for a specific channel.""" + if not self.connection_info: + return + + server_name = self.connection_info.get('server_name', '') + current_settings = self.config_manager.get_channel_speech_settings(server_name, channel_name) + + dialog = QDialog(self) + dialog.setWindowTitle(f"Speech Settings for {channel_name}") + layout = QFormLayout() + + # Voice input + voice_edit = QLineEdit(current_settings.voice) + voice_edit.setPlaceholderText("Leave empty for default") + layout.addRow("Voice:", voice_edit) + + # Rate slider + rate_slider = QSlider(Qt.Horizontal) + rate_slider.setRange(-100, 100) + rate_slider.setValue(current_settings.rate) + rate_label = QLabel(str(current_settings.rate)) + rate_slider.valueChanged.connect(lambda v: rate_label.setText(str(v))) + rate_layout = QHBoxLayout() + rate_layout.addWidget(rate_slider) + rate_layout.addWidget(rate_label) + layout.addRow("Rate:", rate_layout) + + # Module input + module_edit = QLineEdit(current_settings.output_module) + module_edit.setPlaceholderText("Leave empty for default") + layout.addRow("Output Module:", module_edit) + + # Buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addRow(buttons) + + dialog.setLayout(layout) + + if dialog.exec() == QDialog.Accepted: + from src.config.settings import ChannelSpeechSettings + new_settings = ChannelSpeechSettings( + voice=voice_edit.text(), + rate=rate_slider.value(), + output_module=module_edit.text() + ) + self.config_manager.set_channel_speech_settings(server_name, channel_name, new_settings) + self.set_status(f"Speech settings updated for {channel_name}") + + def test_channel_voice(self, channel_name): + """Test the voice for a specific channel.""" + if not self.connection_info: + return + + server_name = self.connection_info.get('server_name', '') + test_message = f" This is a test message for {channel_name}" + self.speak_for_channel(channel_name, test_message) + self.set_status(f"Testing voice for {channel_name}") + + def on_join_channel_clicked(self): + """Handle join channel action.""" + channel, ok = QInputDialog.getText(self, "Join Channel", "Channel name (e.g., #stormux):") + if ok and channel: + if not channel.startswith('#'): + channel = '#' + channel + self.irc_client.join_channel(channel) + + def on_message_send(self): + """Handle message sending.""" + text = self.message_entry.toPlainText().strip() + logger.debug(f"on_message_send called with text: {text}") + if not text: + return + + if text.startswith('/'): + logger.debug(f"Handling command: {text}") + self.handle_command(text) + else: + # Regular messages need a target + if not self.current_target: + logger.warning("No current target for message") + self.append_to_chat("Error: No channel or query selected. Use /join #channel first.") + return + logger.info(f"Sending message to {self.current_target}: {text}") + self.irc_client.send_message(self.current_target, text) + + # Format message with timestamp if enabled + message = f"<{self.irc_client.nickname}> {text}" + ui_config = self.config_manager.get_ui_config() + display_message = format_message_with_timestamp(message, ui_config) + + self.append_to_chat(display_message) + self.append_to_buffer(self.current_target, display_message) + + # Speak own message if enabled + accessibility = self.config_manager.get_accessibility_config() + if accessibility.speech_read_own_messages: + self.speak_for_channel(self.current_target, message) + + # Play sound based on message type + if self.current_target.startswith('#'): + # Public channel message + self.sound_player.play_public_message() + else: + # Private message + self.sound_player.play_private_message() + + self.message_entry.clear() + + def handle_command(self, command): + """Handle IRC commands.""" + # Strip the leading / + raw_command = command[1:].strip() + if not raw_command: + return + + parts = raw_command.split(' ', 1) + cmd = parts[0].upper() + args = parts[1] if len(parts) > 1 else "" + logger.info(f"Processing command: /{cmd} {args}") + + # Handle special client-side commands + if cmd == "QUIT": + logger.info("Quitting IRC") + self.irc_client.send_raw(f"QUIT :{args if args else 'StormIRC'}") + self.irc_client.disconnect() + self.close() + return + + if cmd == "AUTOJOIN": + self.handle_autojoin_command(args) + return + + if cmd == "MSG" or cmd == "QUERY": + # Handle /msg nickname message or /query nickname + if not args: + self.append_to_chat("Usage: /msg [message] or /query ") + return + + msg_parts = args.split(' ', 1) + nickname = msg_parts[0] + message_text = msg_parts[1] if len(msg_parts) > 1 else "" + + # Open PM window + self.open_pm_window(nickname) + + # If there's a message, send it + if message_text: + self.irc_client.send_message(nickname, message_text) + # Play private message sound + self.sound_player.play_private_message() + # Add to PM window + pm_window = self.pm_windows.get(nickname) + if pm_window: + pm_window.add_message(self.irc_client.nickname, message_text) + + return + + # For everything else, just send it as a raw IRC command + if args: + irc_command = f"{cmd} {args}" + else: + irc_command = cmd + + logger.debug(f"Sending raw IRC command: {irc_command}") + self.irc_client.send_raw(irc_command) + + def on_welcome(self, message): + """Handle IRC welcome message.""" + QTimer.singleShot(0, lambda: self.set_status(f"Connected to {self.irc_client.host}")) + QTimer.singleShot(0, lambda: self.join_action.setEnabled(True)) + + # Auto-join channels if configured + if self.connection_info and 'server_config' in self.connection_info: + server_config = self.connection_info['server_config'] + if server_config.auto_join_channels: + logger.info(f"Auto-joining channels: {server_config.auto_join_channels}") + for channel in server_config.auto_join_channels: + self.irc_client.join_channel(channel) + + def on_disconnected(self, message): + """Handle disconnection.""" + QTimer.singleShot(0, lambda: self.set_status("Disconnected")) + QTimer.singleShot(0, lambda: self.join_action.setEnabled(False)) + QTimer.singleShot(0, self.populate_initial_servers) + + def on_error(self, error_msg): + """Handle IRC errors.""" + QTimer.singleShot(0, lambda: self.append_to_chat(f"Error: {error_msg}")) + + def on_sasl_success(self, message): + """Handle SASL success.""" + QTimer.singleShot(0, lambda: self.append_to_chat("SASL authentication successful")) + + def on_sasl_failed(self, message): + """Handle SASL failure.""" + QTimer.singleShot(0, lambda: self.append_to_chat(f"SASL authentication failed: {message}")) + + def on_privmsg(self, message): + """Handle private messages (called from IRC thread).""" + sender = message.prefix.split('!')[0] if message.prefix else "Unknown" + target = message.params[0] if len(message.params) > 0 else "" + text = message.params[1] if len(message.params) > 1 else "" + + logger.debug(f"PRIVMSG: sender={sender}, target={target}, text={text}, our_nick={self.irc_client.nickname}") + + if target.startswith('#'): + # Channel message + formatted_msg = f"<{sender}> {text}" + self.message_received.emit(target, formatted_msg) + else: + # Private message - target is our nickname, sender is who sent it + logger.info(f"Private message from {sender} to {target}: {text}") + # Emit signal to handle in main thread + self.private_message_received.emit(sender, text) + + def on_join(self, message): + """Handle channel join (called from IRC thread).""" + nick = message.prefix.split('!')[0] if message.prefix else "Unknown" + channel = message.params[0] + logger.debug(f"on_join called: nick={nick}, channel={channel}, our_nick={self.irc_client.nickname}") + + if nick == self.irc_client.nickname: + # We joined a channel - emit signal to handle in main thread + logger.info(f"We joined channel {channel}, emitting signal") + self.channel_joined.emit(channel) + else: + # Someone else joined - update user list if this is current channel + self._refresh_user_list_if_current_channel(channel) + + msg = f"* {nick} has joined {channel}" + self.message_received.emit(channel, msg) + + def on_channel_joined_ui(self, channel): + """Handle channel join in main UI thread.""" + logger.info(f"on_channel_joined_ui called for {channel}") + self.add_channel_to_list(channel) + self.switch_to_channel(channel) + + def on_message_received_ui(self, channel, message): + """Handle received message in main UI thread.""" + logger.debug(f"on_message_received_ui: channel={channel}, message={message}") + + # Add timestamp to visual display if enabled in UI settings + ui_config = self.config_manager.get_ui_config() + display_message = format_message_with_timestamp(message, ui_config) + + self.append_to_buffer(channel, display_message) + if channel == self.current_target: + self.append_to_chat(display_message) + + # Speak the message with channel-specific settings (handles speech timestamps separately) + self.speak_for_channel(channel, message) + + # Play sound for received messages (only for actual chat messages, not join/part) + logger.debug(f"Checking if message starts with '<': {message.startswith('<')}") + if message.startswith('<'): + # Check if it's a highlight (nickname mention) + logger.debug(f"Checking highlight for message: {message[:50]}, nickname: {self.irc_client.nickname}") + if self.sound_player.is_highlight(message, self.irc_client.nickname): + logger.debug("Playing highlight sound") + self.sound_player.play_highlight() + elif channel.startswith('#'): + # Regular public channel message + logger.debug("Playing public message sound") + self.sound_player.play_public_message() + # Note: PM sounds are handled in handle_private_message + + def on_part(self, message): + """Handle channel part.""" + nick = message.prefix.split('!')[0] if message.prefix else "Unknown" + channel = message.params[0] + reason = message.params[1] if len(message.params) > 1 else "" + + # Update user list if this is the current channel + self._refresh_user_list_if_current_channel(channel) + + msg = f"* {nick} has left {channel}" + (f" ({reason})" if reason else "") + QTimer.singleShot(0, lambda: self.append_to_buffer(channel, msg)) + if channel == self.current_target: + QTimer.singleShot(0, lambda: self.append_to_chat(msg)) + + def add_channel_to_list(self, channel): + """Add channel to server list.""" + # Find the server item + server_item = self.server_list.topLevelItem(0) + if server_item: + channel_item = QTreeWidgetItem([channel]) + channel_item.setData(0, Qt.UserRole, {"type": "channel", "name": channel}) + server_item.addChild(channel_item) + server_item.setExpanded(True) + self.server_list.setCurrentItem(channel_item) + + # Update autocomplete with all joined channels + self.update_autocomplete_channels() + + def switch_to_channel(self, channel): + """Switch current target to the specified channel.""" + logger.info(f"Switching to channel: {channel}") + self.current_target = channel + self.chat_label.setText(f"Channel: {channel}") + self.load_buffer(channel) + + # Update topic display for channels + if channel.startswith('#'): + topic = self.irc_client.channel_topics.get(channel, "") + if topic: + self.topic_label.setText(f"Topic: {topic}") + self.topic_label.show() + else: + self.topic_label.hide() + + # Update autocomplete nicknames for this channel + users = self.irc_client.channel_users.get(channel, []) + self.message_entry.set_nicknames(sorted(users)) + else: + # Hide topic for non-channels + self.topic_label.hide() + self.message_entry.set_nicknames([]) + + self.message_entry.setFocus() + + def append_to_chat(self, text): + """Append text to chat view.""" + # Store cursor position before appending + from PySide6.QtGui import QTextCursor + cursor = self.chat_view.textCursor() + was_at_end = cursor.atEnd() + + self.chat_view.append(text) + + # If cursor was at end, move it to new end (auto-scroll) + if was_at_end: + cursor.movePosition(QTextCursor.End) + self.chat_view.setTextCursor(cursor) + + def append_to_buffer(self, target, text): + """Append text to target's buffer.""" + if target not in self.chat_buffers: + self.chat_buffers[target] = [] + self.chat_buffers[target].append(text) + + def load_buffer(self, target): + """Load buffer for target.""" + self.chat_view.clear() + if target in self.chat_buffers: + for line in self.chat_buffers[target]: + self.chat_view.append(line) + + # Update user list for this target + if target.startswith('#'): + users = self.irc_client.channel_users.get(target, []) + self.update_user_list(users) + else: + # Query/PM - no user list + self.user_list.clear() + + def on_names_end(self, data): + """Handle end of NAMES list (called from IRC thread).""" + if isinstance(data, dict): + channel = data.get('channel') + users = data.get('users', []) + logger.info(f"on_names_end: {channel} has {len(users)} users") + self.user_list_updated.emit(channel, users) + + def on_user_list_updated_ui(self, channel, users): + """Handle user list update in main UI thread.""" + logger.debug(f"on_user_list_updated_ui: {channel} has {len(users)} users") + # Only update if this is the current channel + if channel == self.current_target: + self.update_user_list(users) + + def on_user_activated(self, item): + """Handle user activation (Enter or double-click on user list).""" + if not item: + return + + nickname = item.text() + logger.info(f"User activated from list: {nickname}") + + # Open PM window with this user + try: + logger.info(f"About to call self.open_pm_window for {nickname}") + self.open_pm_window(nickname) + logger.info(f"Successfully called open_pm_window") + except Exception as e: + logger.error(f"Exception in on_user_activated: {e}") + import traceback + logger.error(traceback.format_exc()) + + def on_quit(self, message): + """Handle user quit.""" + nick = message.prefix.split('!')[0] if message.prefix else "Unknown" + reason = message.params[0] if message.params else "" + msg = f"* {nick} has quit" + (f" ({reason})" if reason else "") + + # Update user list for current channel and show message + def update_ui(): + if self.current_target.startswith('#'): + users = self.irc_client.channel_users.get(self.current_target, []) + self.update_user_list(users) + + # Show quit message in all channels the user was in + for channel in self.irc_client.channels: + self.append_to_buffer(channel, msg) + if channel == self.current_target: + self.append_to_chat(msg) + + QTimer.singleShot(0, update_ui) + + def on_nick(self, message): + """Handle nickname change.""" + if not message.prefix or len(message.params) < 1: + return + + old_nick = message.prefix.split('!')[0] + new_nick = message.params[0] + msg = f"* {old_nick} is now known as {new_nick}" + + # Update user list for current channel and show message + def update_ui(): + if self.current_target.startswith('#'): + users = self.irc_client.channel_users.get(self.current_target, []) + self.update_user_list(users) + + # Show nick change in all channels the user is in + for channel in self.irc_client.channels: + self.append_to_buffer(channel, msg) + if channel == self.current_target: + self.append_to_chat(msg) + + QTimer.singleShot(0, update_ui) + + def _refresh_user_list_if_current_channel(self, channel): + """Helper to refresh user list if the given channel is currently active. + + This consolidates the common pattern of checking if a channel is current + and emitting the user_list_updated signal with current users. + + Args: + channel: Channel name to check and update + """ + if channel == self.current_target: + users = self.irc_client.channel_users.get(channel, []) + self.user_list_updated.emit(channel, users) + + def update_user_list(self, users): + """Update the user list widget.""" + # Block signals during batch update to reduce overhead + self.user_list.blockSignals(True) + self.user_list.clear() + # Sort users alphabetically + sorted_users = sorted(users) + self.user_list.addItems(sorted_users) # Batch add instead of one-by-one + self.user_list.blockSignals(False) + logger.debug(f"Updated user list: {len(users)} users") + + # Update autocomplete nicknames for current channel + self.message_entry.set_nicknames(sorted_users) + + def update_autocomplete_channels(self): + """Update the autocomplete widget with all joined channels.""" + channels = [] + server_item = self.server_list.topLevelItem(0) + if server_item: + for i in range(server_item.childCount()): + channel_item = server_item.child(i) + channel_name = channel_item.text(0) + if channel_name.startswith('#'): + # Remove the # for autocomplete list + channels.append(channel_name[1:]) + self.message_entry.set_channels(channels) + logger.debug(f"Updated autocomplete channels: {channels}") + + def on_list_end(self, channel_list): + """Handle end of LIST command (called from IRC thread).""" + logger.info(f"on_list_end: received {len(channel_list)} channels") + self.channel_list_ready.emit(channel_list) + + def on_topic(self, topic_data): + """Handle topic events (called from IRC thread).""" + channel = topic_data['channel'] + topic = topic_data['topic'] + setter = topic_data.get('setter') + + logger.info(f"Topic for {channel}: {topic}") + + # Use QTimer to update UI in main thread + QTimer.singleShot(0, lambda: self.update_topic_ui(channel, topic, setter)) + + def update_topic_ui(self, channel, topic, setter): + """Update topic display in UI (main thread).""" + # If we're currently viewing this channel, update the display + if self.current_target == channel: + if topic: + self.topic_label.setText(f"Topic: {topic}") + self.topic_label.show() + else: + self.topic_label.hide() + + # Show topic change message in channel + if setter: + self.append_to_buffer(channel, f"* {setter} changed topic to: {topic}") + if self.current_target == channel: + self.append_to_chat(f"* {setter} changed topic to: {topic}") + else: + # Initial topic on join + self.append_to_buffer(channel, f"* Topic: {topic}") + if self.current_target == channel and topic: + self.append_to_chat(f"* Topic: {topic}") + + def on_channel_list_ready_ui(self, channel_list): + """Handle channel list ready in main UI thread.""" + logger.info(f"on_channel_list_ready_ui: showing {len(channel_list)} channels") + dialog = ChannelListDialog(self, channel_list, self.irc_client) + dialog.exec() + + def handle_autojoin_command(self, args): + """Handle /autojoin command to manage autojoin channels.""" + if not self.connection_info or 'server_config' not in self.connection_info: + self.append_to_chat("Error: Not connected to a server") + return + + server_config = self.connection_info['server_config'] + + # No args - show current autojoin list + if not args: + if server_config.auto_join_channels: + channels_str = ", ".join(server_config.auto_join_channels) + self.append_to_chat(f"Auto-join channels: {channels_str}") + else: + self.append_to_chat("No auto-join channels configured") + return + + # Parse arguments + parts = args.split(' ', 1) + subcommand = parts[0].upper() + channels_arg = parts[1] if len(parts) > 1 else "" + + if subcommand == "ADD": + if not channels_arg: + self.append_to_chat("Usage: /autojoin add #channel1,#channel2") + return + + # Parse channels + channels = [ch.strip() for ch in channels_arg.split(',') if ch.strip()] + channels = [ch if ch.startswith('#') else f'#{ch}' for ch in channels] + + # Add to autojoin list + for channel in channels: + if channel not in server_config.auto_join_channels: + server_config.auto_join_channels.append(channel) + self.append_to_chat(f"Added {channel} to auto-join list") + else: + self.append_to_chat(f"{channel} is already in auto-join list") + + # Save config + self.config_manager.save_config() + + elif subcommand == "REMOVE" or subcommand == "DEL": + if not channels_arg: + self.append_to_chat("Usage: /autojoin remove #channel1,#channel2") + return + + # Parse channels + channels = [ch.strip() for ch in channels_arg.split(',') if ch.strip()] + channels = [ch if ch.startswith('#') else f'#{ch}' for ch in channels] + + # Remove from autojoin list + for channel in channels: + if channel in server_config.auto_join_channels: + server_config.auto_join_channels.remove(channel) + self.append_to_chat(f"Removed {channel} from auto-join list") + else: + self.append_to_chat(f"{channel} is not in auto-join list") + + # Save config + self.config_manager.save_config() + + elif subcommand == "CLEAR": + server_config.auto_join_channels = [] + self.append_to_chat("Cleared auto-join list") + self.config_manager.save_config() + + elif subcommand == "LIST": + if server_config.auto_join_channels: + channels_str = ", ".join(server_config.auto_join_channels) + self.append_to_chat(f"Auto-join channels: {channels_str}") + else: + self.append_to_chat("No auto-join channels configured") + + else: + self.append_to_chat("Usage: /autojoin [add|remove|clear|list] [channels]") + self.append_to_chat(" /autojoin - Show current auto-join list") + self.append_to_chat(" /autojoin add #channel1,#channel2 - Add channels") + self.append_to_chat(" /autojoin remove #channel - Remove channel") + self.append_to_chat(" /autojoin clear - Clear all channels") + self.append_to_chat(" /autojoin list - Show current list") + + def handle_private_message(self, sender, text): + """Handle incoming private message (UI thread).""" + logger.info(f"handle_private_message: from {sender}") + + # Play private message sound + self.sound_player.play_private_message() + + # Get or create PM window + pm_window = self.get_or_create_pm_window(sender) + + # Add message to window + pm_window.add_message(sender, text) + + # Show and raise window + pm_window.show_and_raise() + + def get_or_create_pm_window(self, nickname): + """Get existing PM window or create new one.""" + if nickname not in self.pm_windows: + logger.info(f"Creating new PM window for {nickname}") + server_name = self.connection_info.get('server_name', '') if self.connection_info else '' + pm_window = PMWindow(nickname, self.irc_client, self.speech_manager, self.config_manager, server_name, self) + + # Connect signals + pm_window.message_send.connect(self.on_pm_message_send) + pm_window.window_closed.connect(self.on_pm_window_closed) + + self.pm_windows[nickname] = pm_window + else: + logger.debug(f"Using existing PM window for {nickname}") + + return self.pm_windows[nickname] + + def on_pm_message_send(self, nickname, message): + """Handle sending PM from PM window.""" + logger.info(f"Sending PM to {nickname}: {message}") + self.irc_client.send_message(nickname, message) + # Play private message sound + self.sound_player.play_private_message() + + def on_pm_window_closed(self, nickname): + """Handle PM window closing.""" + logger.info(f"PM window closed for {nickname}") + if nickname in self.pm_windows: + del self.pm_windows[nickname] + + def open_pm_window(self, nickname): + """Open PM window for a specific user (called from /msg or user list).""" + logger.info(f"open_pm_window called for {nickname}") + pm_window = self.get_or_create_pm_window(nickname) + logger.info(f"PM window created/retrieved, calling show_and_raise") + pm_window.show_and_raise() + logger.info(f"PM window shown for {nickname}") + + +class ConnectDialog(QDialog): + """Connection dialog for manual server connection.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Connect to IRC Server") + self.setup_ui() + + def setup_ui(self): + """Set up dialog UI.""" + layout = QFormLayout(self) + + # Server hostname + self.host_edit = QLineEdit("irc.libera.chat") + self.host_edit.setAccessibleName("Server hostname") + self.host_edit.setAccessibleDescription("IRC server hostname or IP address") + layout.addRow("&Server:", self.host_edit) + + # Port + self.port_spin = QSpinBox() + self.port_spin.setRange(1, 65535) + self.port_spin.setValue(6667) + self.port_spin.setAccessibleName("Port number") + self.port_spin.setAccessibleDescription("IRC server port, typically 6667 for plain or 6697 for SSL") + layout.addRow("&Port:", self.port_spin) + + # SSL checkbox + self.ssl_check = QCheckBox("Use SSL/TLS") + self.ssl_check.setAccessibleName("Use SSL") + self.ssl_check.setAccessibleDescription("Enable SSL/TLS encrypted connection") + layout.addRow("", self.ssl_check) + + # Nickname + self.nick_edit = QLineEdit("StormUser") + self.nick_edit.setAccessibleName("Nickname") + self.nick_edit.setAccessibleDescription("Your IRC nickname") + layout.addRow("&Nickname:", self.nick_edit) + + # Username + self.user_edit = QLineEdit("stormuser") + self.user_edit.setAccessibleName("Username") + self.user_edit.setAccessibleDescription("Username for your connection, shown in WHOIS") + layout.addRow("&Username:", self.user_edit) + + # Real name + self.real_edit = QLineEdit("Storm User") + self.real_edit.setAccessibleName("Real name") + self.real_edit.setAccessibleDescription("Real name or description, shown in WHOIS") + layout.addRow("&Real name:", self.real_edit) + + # SASL section + self.sasl_user_edit = QLineEdit() + self.sasl_user_edit.setPlaceholderText("Optional") + self.sasl_user_edit.setAccessibleName("SASL username") + self.sasl_user_edit.setAccessibleDescription("SASL username for authentication, typically your registered account name. Leave empty to skip SASL") + layout.addRow("SASL &Username:", self.sasl_user_edit) + + self.sasl_pass_edit = QLineEdit() + self.sasl_pass_edit.setEchoMode(QLineEdit.Password) + self.sasl_pass_edit.setPlaceholderText("Optional") + self.sasl_pass_edit.setAccessibleName("SASL password") + self.sasl_pass_edit.setAccessibleDescription("SASL password for authentication. Leave empty to skip SASL") + layout.addRow("SASL &Password:", self.sasl_pass_edit) + + # Auto-connect checkbox + self.auto_connect_check = QCheckBox("Auto-connect on startup") + self.auto_connect_check.setAccessibleName("Auto-connect") + self.auto_connect_check.setAccessibleDescription("Automatically connect to this server when StormIRC starts") + layout.addRow("", self.auto_connect_check) + + # Auto-join channels + self.autojoin_edit = QLineEdit() + self.autojoin_edit.setPlaceholderText("e.g., #channel1,#channel2") + self.autojoin_edit.setAccessibleName("Auto-join channels") + self.autojoin_edit.setAccessibleDescription("Comma-separated list of channels to join automatically after connecting") + layout.addRow("Auto-&join channels:", self.autojoin_edit) + + # Buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + # Set initial focus + self.host_edit.setFocus() + + def get_server_config(self): + """Get server configuration from dialog.""" + config = ServerConfig() + config.name = f"{self.host_edit.text()}:{self.port_spin.value()}" + config.host = self.host_edit.text() + config.port = self.port_spin.value() + config.use_ssl = self.ssl_check.isChecked() + config.nickname = self.nick_edit.text() + config.username = self.user_edit.text() + config.realname = self.real_edit.text() + config.sasl_username = self.sasl_user_edit.text() + config.sasl_password = self.sasl_pass_edit.text() + config.auto_connect = self.auto_connect_check.isChecked() + + # Parse autojoin channels + autojoin_text = self.autojoin_edit.text().strip() + if autojoin_text: + # Split by comma and strip whitespace + channels = [ch.strip() for ch in autojoin_text.split(',') if ch.strip()] + # Ensure channels start with # + config.auto_join_channels = [ch if ch.startswith('#') else f'#{ch}' for ch in channels] + else: + config.auto_join_channels = [] + + return config + + +class ChannelListDialog(QDialog): + """Dialog to display list of available channels.""" + + def __init__(self, parent=None, channel_list=None, irc_client=None): + super().__init__(parent) + self.channel_list = channel_list or [] + self.irc_client = irc_client + self.setWindowTitle("Channel List") + self.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) + self.setup_ui() + + def setup_ui(self): + """Set up dialog UI.""" + layout = QVBoxLayout(self) + + # Info label + info_label = QLabel(f"Found {len(self.channel_list)} channels") + info_label.setAccessibleName("Channel count") + layout.addWidget(info_label) + + # Table widget + self.table = QTableWidget() + self.table.setColumnCount(3) + self.table.setHorizontalHeaderLabels(["Channel", "Users", "Topic"]) + self.table.setAccessibleName("Channel list table") + self.table.setAccessibleDescription("List of available channels. Select a channel and press Enter or click Join to join it.") + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.table.itemDoubleClicked.connect(self.on_channel_double_clicked) + + # Populate table + self.table.setRowCount(len(self.channel_list)) + for row, channel_info in enumerate(self.channel_list): + # Channel name + channel_item = QTableWidgetItem(channel_info['channel']) + self.table.setItem(row, 0, channel_item) + + # User count + users_item = QTableWidgetItem(channel_info['users']) + self.table.setItem(row, 1, users_item) + + # Topic + topic_item = QTableWidgetItem(channel_info['topic']) + self.table.setItem(row, 2, topic_item) + + layout.addWidget(self.table) + + # Buttons + button_layout = QHBoxLayout() + + join_button = QPushButton("Join") + join_button.clicked.connect(self.on_join_clicked) + button_layout.addWidget(join_button) + + close_button = QPushButton("Close") + close_button.clicked.connect(self.reject) + button_layout.addWidget(close_button) + + layout.addLayout(button_layout) + + # Set focus to table + self.table.setFocus() + + def on_channel_double_clicked(self, item): + """Handle double-click on channel.""" + self.on_join_clicked() + + def on_join_clicked(self): + """Handle join button click.""" + selected_rows = self.table.selectionModel().selectedRows() + if not selected_rows: + return + + row = selected_rows[0].row() + channel = self.table.item(row, 0).text() + + if self.irc_client: + self.irc_client.join_channel(channel) + + self.accept() + diff --git a/src/ui/pm_window.py b/src/ui/pm_window.py new file mode 100644 index 0000000..b3df8c4 --- /dev/null +++ b/src/ui/pm_window.py @@ -0,0 +1,200 @@ +"""Private message window for StormIRC.""" + +import logging +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QTextEdit, + QPushButton, QHBoxLayout, QLabel, QPlainTextEdit +) +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAccessible +from src.ui.autocomplete_textedit import AutocompleteTextEdit +from src.config.settings import is_valid_text +from src.ui.ui_utils import ( + format_message_with_timestamp, + apply_speech_settings, + DEFAULT_PM_WINDOW_WIDTH, + DEFAULT_PM_WINDOW_HEIGHT, + PM_MESSAGE_ENTRY_MAX_HEIGHT +) + +logger = logging.getLogger(__name__) + + +class PMWindow(QMainWindow): + """Private message window.""" + + message_send = Signal(str, str) # Signal(nickname, message) + window_closed = Signal(str) # Signal(nickname) when window closes + + def __init__(self, nickname, irc_client, speech_manager, config_manager, server_name, parent=None): + super().__init__(parent) + self.nickname = nickname + self.irc_client = irc_client + self.speech_manager = speech_manager + self.config_manager = config_manager + self.server_name = server_name + self.message_buffer = [] + + # Set window title with format: "nickname - Private Message" + self.setWindowTitle(f"{nickname} - Private Message") + self.resize(DEFAULT_PM_WINDOW_WIDTH, DEFAULT_PM_WINDOW_HEIGHT) + + self.setup_ui() + + def setup_ui(self): + """Set up the user interface.""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + layout = QVBoxLayout(central_widget) + + # Header with nickname + header_label = QLabel(f"Private conversation with {self.nickname}") + header_label.setStyleSheet("font-weight: bold; padding: 5px;") + layout.addWidget(header_label) + + # Chat display + self.chat_display = QTextEdit() + self.chat_display.setReadOnly(True) + # Enable cursor for screen reader navigation (same as main window) + self.chat_display.setTextInteractionFlags( + Qt.TextSelectableByMouse | + Qt.TextSelectableByKeyboard | + Qt.LinksAccessibleByMouse | + Qt.LinksAccessibleByKeyboard + ) + self.chat_display.setFocusPolicy(Qt.StrongFocus) + self.chat_display.setAccessibleName(f"Private message conversation with {self.nickname}") + self.chat_display.setAccessibleDescription( + f"Chat history with {self.nickname}. Use arrow keys to navigate. Press Tab to move to message input." + ) + layout.addWidget(self.chat_display) + + # Message input area + input_layout = QVBoxLayout() + + input_label = QLabel("Message:") + input_layout.addWidget(input_label) + + # Use autocomplete text edit for nickname completion + self.message_entry = AutocompleteTextEdit() + self.message_entry.setAccessibleName(f"Message input for {self.nickname}") + self.message_entry.setAccessibleDescription( + f"Type your message to {self.nickname} here. Press Enter to send, Shift+Enter for new line." + ) + self.message_entry.setPlaceholderText(f"Type message to {self.nickname}...") + self.message_entry.setMaximumHeight(PM_MESSAGE_ENTRY_MAX_HEIGHT) + + # Autocomplete will be set up by parent when needed + + # Connect Enter key handling + self.message_entry.installEventFilter(self) + input_layout.addWidget(self.message_entry) + + # Send button + button_layout = QHBoxLayout() + self.send_button = QPushButton("Send") + self.send_button.setAccessibleName("Send message button") + self.send_button.clicked.connect(self.on_send_message) + button_layout.addWidget(self.send_button) + button_layout.addStretch() + + input_layout.addLayout(button_layout) + layout.addLayout(input_layout) + + # Set focus to message entry + self.message_entry.setFocus() + + def eventFilter(self, obj, event): + """Event filter to handle Enter key in message entry.""" + from PySide6.QtCore import QEvent + + if obj == self.message_entry and event.type() == QEvent.KeyPress: + # Enter without Shift sends message, Shift+Enter inserts newline + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + if not (event.modifiers() & Qt.ShiftModifier): + self.on_send_message() + return True # Event handled + return super().eventFilter(obj, event) + + def keyPressEvent(self, event): + """Handle key press events - Ctrl stops all speech, Escape closes window.""" + if event.key() == Qt.Key_Control: + self.speech_manager.stop() + elif event.key() == Qt.Key_Escape: + self.close() + return + super().keyPressEvent(event) + + def speak_message(self, text: str): + """Speak message with PM-specific settings.""" + accessibility = self.config_manager.get_accessibility_config() + + # Check if speech is enabled and auto-read is on + if not accessibility.speech_enabled or not accessibility.auto_read_messages: + return + + # Validate text has meaningful content + if not is_valid_text(text): + return + + # Get PM-specific settings + channel_settings = self.config_manager.get_channel_speech_settings(self.server_name, self.nickname) + + # Apply PM-specific settings using shared utility + apply_speech_settings(self.speech_manager, channel_settings, self.nickname) + + # Speak the text + self.speech_manager.speak(text, priority="message", interrupt=False) + + def on_send_message(self): + """Handle send message.""" + message = self.message_entry.toPlainText().strip() + if not message: + return + + logger.debug(f"Sending PM to {self.nickname}: {message}") + + # Emit signal to send message + self.message_send.emit(self.nickname, message) + + # Display our message locally + self.add_message(self.irc_client.nickname, message) + + # Clear input + self.message_entry.clear() + self.message_entry.setFocus() + + def add_message(self, sender, message): + """Add message to chat display.""" + formatted_msg = f"<{sender}> {message}" + self.message_buffer.append(formatted_msg) + + # Update display + self.chat_display.append(formatted_msg) + + # Speak the message + is_own_message = (sender == self.irc_client.nickname) + if is_own_message: + # Check if "read my own messages" is enabled + accessibility = self.config_manager.get_accessibility_config() + if accessibility.speech_read_own_messages: + self.speak_message(formatted_msg) + else: + # Always speak incoming messages + self.speak_message(formatted_msg) + + logger.debug(f"PM window [{self.nickname}]: Added message from {sender}") + + def closeEvent(self, event): + """Handle window close.""" + logger.info(f"PM window closing for {self.nickname}") + self.window_closed.emit(self.nickname) + super().closeEvent(event) + + def show_and_raise(self): + """Show window and bring to front.""" + self.show() + self.raise_() + self.activateWindow() + self.message_entry.setFocus() diff --git a/src/ui/settings_dialog.py b/src/ui/settings_dialog.py new file mode 100644 index 0000000..f07f0d4 --- /dev/null +++ b/src/ui/settings_dialog.py @@ -0,0 +1,1426 @@ +"""Settings dialog for StormIRC.""" + +import logging +from pathlib import Path +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, + QWidget, QLabel, QLineEdit, QSpinBox, QCheckBox, + QComboBox, QPushButton, QGroupBox, QListWidget, + QTableWidget, QTableWidgetItem, QFormLayout, + QDialogButtonBox, QSlider, QMessageBox, QFileDialog +) +from PySide6.QtCore import Qt, Signal +from src.config.settings import ( + ConfigManager, AccessibilityConfig, UIConfig, ServerConfig, ChannelSpeechSettings +) + +logger = logging.getLogger(__name__) + + +class GeneralSettingsTab(QWidget): + """General/UI settings tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # UI Settings Group + ui_group = QGroupBox("User Interface") + ui_layout = QFormLayout() + + # Window dimensions + self.window_width_spin = QSpinBox() + self.window_width_spin.setRange(400, 3840) + self.window_width_spin.setValue(self.config_manager.config.ui.window_width) + self.window_width_spin.setAccessibleName("Window width") + ui_layout.addRow("Window Width:", self.window_width_spin) + + self.window_height_spin = QSpinBox() + self.window_height_spin.setRange(300, 2160) + self.window_height_spin.setValue(self.config_manager.config.ui.window_height) + self.window_height_spin.setAccessibleName("Window height") + ui_layout.addRow("Window Height:", self.window_height_spin) + + # Channel list width + self.channel_list_width_spin = QSpinBox() + self.channel_list_width_spin.setRange(100, 500) + self.channel_list_width_spin.setValue(self.config_manager.config.ui.channel_list_width) + self.channel_list_width_spin.setAccessibleName("Channel list width") + ui_layout.addRow("Channel List Width:", self.channel_list_width_spin) + + # Timestamps + self.show_timestamps_check = QCheckBox("Show timestamps in messages") + self.show_timestamps_check.setChecked(self.config_manager.config.ui.show_timestamps) + ui_layout.addRow(self.show_timestamps_check) + + self.timestamp_format_edit = QLineEdit() + self.timestamp_format_edit.setText(self.config_manager.config.ui.timestamp_format) + self.timestamp_format_edit.setAccessibleName("Timestamp format") + self.timestamp_format_edit.setPlaceholderText("[%H:%M:%S]") + ui_layout.addRow("Timestamp Format:", self.timestamp_format_edit) + + # Theme + self.theme_combo = QComboBox() + self.theme_combo.addItems(["default", "dark", "light"]) + self.theme_combo.setCurrentText(self.config_manager.config.ui.theme) + self.theme_combo.setAccessibleName("Theme") + ui_layout.addRow("Theme:", self.theme_combo) + + # Channel list position + self.channel_position_combo = QComboBox() + self.channel_position_combo.addItems(["left", "right"]) + self.channel_position_combo.setCurrentText(self.config_manager.config.ui.channel_list_position) + self.channel_position_combo.setAccessibleName("Channel list position") + ui_layout.addRow("Channel List Position:", self.channel_position_combo) + + ui_group.setLayout(ui_layout) + layout.addWidget(ui_group) + + layout.addStretch() + self.setLayout(layout) + + def save_settings(self): + """Save settings.""" + ui_config = UIConfig( + window_width=self.window_width_spin.value(), + window_height=self.window_height_spin.value(), + channel_list_width=self.channel_list_width_spin.value(), + show_timestamps=self.show_timestamps_check.isChecked(), + timestamp_format=self.timestamp_format_edit.text(), + theme=self.theme_combo.currentText(), + channel_list_position=self.channel_position_combo.currentText() + ) + self.config_manager.update_ui_config(ui_config) + + +class AccessibilitySettingsTab(QWidget): + """Accessibility settings tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Screen Reader Group + sr_group = QGroupBox("Screen Reader") + sr_layout = QVBoxLayout() + + self.sr_announcements_check = QCheckBox("Enable screen reader announcements") + self.sr_announcements_check.setChecked( + self.config_manager.config.accessibility.screen_reader_announcements + ) + sr_layout.addWidget(self.sr_announcements_check) + + self.announce_joins_check = QCheckBox("Announce user joins and parts") + self.announce_joins_check.setChecked( + self.config_manager.config.accessibility.announce_joins_parts + ) + sr_layout.addWidget(self.announce_joins_check) + + self.announce_nick_check = QCheckBox("Announce nick changes") + self.announce_nick_check.setChecked( + self.config_manager.config.accessibility.announce_nick_changes + ) + sr_layout.addWidget(self.announce_nick_check) + + sr_group.setLayout(sr_layout) + layout.addWidget(sr_group) + + # Notifications Group + notif_group = QGroupBox("Notifications") + notif_layout = QVBoxLayout() + + self.enable_notifications_check = QCheckBox("Enable system notifications") + self.enable_notifications_check.setChecked( + self.config_manager.config.accessibility.enable_notifications + ) + notif_layout.addWidget(self.enable_notifications_check) + + self.enable_sounds_check = QCheckBox("Enable notification sounds") + self.enable_sounds_check.setChecked( + self.config_manager.config.accessibility.enable_sounds + ) + notif_layout.addWidget(self.enable_sounds_check) + + # Sound Options (individual control) + sound_options_label = QLabel("Sound Options:") + sound_options_label.setStyleSheet("margin-left: 20px; font-style: italic;") + notif_layout.addWidget(sound_options_label) + + self.sound_public_message_check = QCheckBox("Play sound for public messages") + self.sound_public_message_check.setChecked( + self.config_manager.config.accessibility.sound_public_message + ) + self.sound_public_message_check.setStyleSheet("margin-left: 20px;") + self.sound_public_message_check.setAccessibleName("Enable public message sound") + notif_layout.addWidget(self.sound_public_message_check) + + self.sound_private_message_check = QCheckBox("Play sound for private messages") + self.sound_private_message_check.setChecked( + self.config_manager.config.accessibility.sound_private_message + ) + self.sound_private_message_check.setStyleSheet("margin-left: 20px;") + self.sound_private_message_check.setAccessibleName("Enable private message sound") + notif_layout.addWidget(self.sound_private_message_check) + + self.sound_highlight_check = QCheckBox("Play sound for highlights (mentions)") + self.sound_highlight_check.setChecked( + self.config_manager.config.accessibility.sound_highlight + ) + self.sound_highlight_check.setStyleSheet("margin-left: 20px;") + self.sound_highlight_check.setAccessibleName("Enable highlight sound") + notif_layout.addWidget(self.sound_highlight_check) + + self.mention_highlights_check = QCheckBox("Highlight nickname mentions") + self.mention_highlights_check.setChecked( + self.config_manager.config.accessibility.mention_highlights + ) + notif_layout.addWidget(self.mention_highlights_check) + + notif_group.setLayout(notif_layout) + layout.addWidget(notif_group) + + # Display Group + display_group = QGroupBox("Display") + display_layout = QFormLayout() + + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 32) + self.font_size_spin.setValue(self.config_manager.config.accessibility.font_size) + self.font_size_spin.setAccessibleName("Font size") + display_layout.addRow("Font Size:", self.font_size_spin) + + self.history_lines_spin = QSpinBox() + self.history_lines_spin.setRange(100, 10000) + self.history_lines_spin.setValue( + self.config_manager.config.accessibility.chat_history_lines + ) + self.history_lines_spin.setAccessibleName("Chat history lines") + display_layout.addRow("Chat History Lines:", self.history_lines_spin) + + self.high_contrast_check = QCheckBox("High contrast mode") + self.high_contrast_check.setChecked( + self.config_manager.config.accessibility.high_contrast + ) + display_layout.addRow(self.high_contrast_check) + + display_group.setLayout(display_layout) + layout.addWidget(display_group) + + # Keyboard Group + keyboard_group = QGroupBox("Keyboard") + keyboard_layout = QVBoxLayout() + + self.keyboard_shortcuts_check = QCheckBox("Enable keyboard shortcuts") + self.keyboard_shortcuts_check.setChecked( + self.config_manager.config.accessibility.keyboard_shortcuts_enabled + ) + keyboard_layout.addWidget(self.keyboard_shortcuts_check) + + keyboard_group.setLayout(keyboard_layout) + layout.addWidget(keyboard_group) + + layout.addStretch() + self.setLayout(layout) + + def save_settings(self): + """Save settings.""" + accessibility_config = AccessibilityConfig( + enable_notifications=self.enable_notifications_check.isChecked(), + enable_sounds=self.enable_sounds_check.isChecked(), + announce_joins_parts=self.announce_joins_check.isChecked(), + announce_nick_changes=self.announce_nick_check.isChecked(), + mention_highlights=self.mention_highlights_check.isChecked(), + chat_history_lines=self.history_lines_spin.value(), + font_size=self.font_size_spin.value(), + high_contrast=self.high_contrast_check.isChecked(), + screen_reader_announcements=self.sr_announcements_check.isChecked(), + keyboard_shortcuts_enabled=self.keyboard_shortcuts_check.isChecked(), + sound_public_message=self.sound_public_message_check.isChecked(), + sound_private_message=self.sound_private_message_check.isChecked(), + sound_highlight=self.sound_highlight_check.isChecked(), + speech_enabled=self.config_manager.config.accessibility.speech_enabled, + speech_rate=self.config_manager.config.accessibility.speech_rate, + speech_pitch=self.config_manager.config.accessibility.speech_pitch, + speech_volume=self.config_manager.config.accessibility.speech_volume, + speech_voice=self.config_manager.config.accessibility.speech_voice + ) + self.config_manager.update_accessibility_config(accessibility_config) + + +class SpeechSettingsTab(QWidget): + """Speech/TTS settings tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Enable speech + self.speech_enabled_check = QCheckBox("Enable text-to-speech") + self.speech_enabled_check.setChecked( + self.config_manager.config.accessibility.speech_enabled + ) + layout.addWidget(self.speech_enabled_check) + + # Auto-read messages + self.auto_read_check = QCheckBox("Automatically read incoming messages") + self.auto_read_check.setChecked( + self.config_manager.config.accessibility.auto_read_messages + ) + layout.addWidget(self.auto_read_check) + + # Read own messages + self.read_own_messages_check = QCheckBox("Read my own messages aloud when sent") + self.read_own_messages_check.setChecked( + self.config_manager.config.accessibility.speech_read_own_messages + ) + layout.addWidget(self.read_own_messages_check) + + # Timestamp reading option + self.read_timestamps_check = QCheckBox("Read timestamps aloud (when timestamps enabled in General)") + self.read_timestamps_check.setChecked( + self.config_manager.config.accessibility.speech_read_timestamps + ) + layout.addWidget(self.read_timestamps_check) + + # Speech parameters group + params_group = QGroupBox("Speech Parameters") + params_layout = QFormLayout() + + # Speech rate + rate_layout = QHBoxLayout() + self.speech_rate_slider = QSlider(Qt.Horizontal) + self.speech_rate_slider.setRange(-100, 100) + self.speech_rate_slider.setValue(self.config_manager.config.accessibility.speech_rate) + self.speech_rate_slider.setAccessibleName("Speech rate") + self.speech_rate_label = QLabel(str(self.speech_rate_slider.value())) + self.speech_rate_slider.valueChanged.connect( + lambda v: self.speech_rate_label.setText(str(v)) + ) + rate_layout.addWidget(self.speech_rate_slider) + rate_layout.addWidget(self.speech_rate_label) + params_layout.addRow("Speech Rate:", rate_layout) + + # Speech pitch + pitch_layout = QHBoxLayout() + self.speech_pitch_slider = QSlider(Qt.Horizontal) + self.speech_pitch_slider.setRange(-100, 100) + self.speech_pitch_slider.setValue(self.config_manager.config.accessibility.speech_pitch) + self.speech_pitch_slider.setAccessibleName("Speech pitch") + self.speech_pitch_label = QLabel(str(self.speech_pitch_slider.value())) + self.speech_pitch_slider.valueChanged.connect( + lambda v: self.speech_pitch_label.setText(str(v)) + ) + pitch_layout.addWidget(self.speech_pitch_slider) + pitch_layout.addWidget(self.speech_pitch_label) + params_layout.addRow("Speech Pitch:", pitch_layout) + + # Speech volume + volume_layout = QHBoxLayout() + self.speech_volume_slider = QSlider(Qt.Horizontal) + self.speech_volume_slider.setRange(-100, 100) + self.speech_volume_slider.setValue(self.config_manager.config.accessibility.speech_volume) + self.speech_volume_slider.setAccessibleName("Speech volume") + self.speech_volume_label = QLabel(str(self.speech_volume_slider.value())) + self.speech_volume_slider.valueChanged.connect( + lambda v: self.speech_volume_label.setText(str(v)) + ) + volume_layout.addWidget(self.speech_volume_slider) + volume_layout.addWidget(self.speech_volume_label) + params_layout.addRow("Speech Volume:", volume_layout) + + # Output module selection (before voice so voice list can update based on module) + self.speech_module_combo = QComboBox() + self.speech_module_combo.setAccessibleName("Speech output module") + self.speech_module_combo.setEditable(False) + self.speech_module_combo.currentIndexChanged.connect(self.on_global_module_changed) + params_layout.addRow("Output Module:", self.speech_module_combo) + + # Voice selection (comes after module) + self.speech_voice_combo = QComboBox() + self.speech_voice_combo.setAccessibleName("Speech voice") + self.speech_voice_combo.setEditable(False) + params_layout.addRow("Voice:", self.speech_voice_combo) + + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + # Default settings for new channels + channel_defaults_group = QGroupBox("Default Settings for New Public Channels") + channel_defaults_layout = QFormLayout() + + # Module first + self.default_channel_module_combo = QComboBox() + self.default_channel_module_combo.setAccessibleName("Default channel output module") + self.default_channel_module_combo.setEditable(False) + self.default_channel_module_combo.currentIndexChanged.connect(self.on_channel_module_changed) + channel_defaults_layout.addRow("Module:", self.default_channel_module_combo) + + # Then voice + self.default_channel_voice_combo = QComboBox() + self.default_channel_voice_combo.setAccessibleName("Default channel voice") + self.default_channel_voice_combo.setEditable(False) + channel_defaults_layout.addRow("Voice:", self.default_channel_voice_combo) + + # Then rate + self.default_channel_rate_slider = QSlider(Qt.Horizontal) + self.default_channel_rate_slider.setRange(-100, 100) + self.default_channel_rate_slider.setValue( + self.config_manager.config.accessibility.default_channel_rate + ) + self.default_channel_rate_label = QLabel(str(self.default_channel_rate_slider.value())) + self.default_channel_rate_slider.valueChanged.connect( + lambda v: self.default_channel_rate_label.setText(str(v)) + ) + rate_layout_ch = QHBoxLayout() + rate_layout_ch.addWidget(self.default_channel_rate_slider) + rate_layout_ch.addWidget(self.default_channel_rate_label) + channel_defaults_layout.addRow("Rate:", rate_layout_ch) + + # Test button for channel defaults + self.test_channel_button = QPushButton("Test Channel Voice") + self.test_channel_button.clicked.connect(self.test_channel_voice) + channel_defaults_layout.addRow("", self.test_channel_button) + + channel_defaults_group.setLayout(channel_defaults_layout) + layout.addWidget(channel_defaults_group) + + # Default settings for new PMs + pm_defaults_group = QGroupBox("Default Settings for New Private Messages") + pm_defaults_layout = QFormLayout() + + # Module first + self.default_pm_module_combo = QComboBox() + self.default_pm_module_combo.setAccessibleName("Default PM output module") + self.default_pm_module_combo.setEditable(False) + self.default_pm_module_combo.currentIndexChanged.connect(self.on_pm_module_changed) + pm_defaults_layout.addRow("Module:", self.default_pm_module_combo) + + # Then voice + self.default_pm_voice_combo = QComboBox() + self.default_pm_voice_combo.setAccessibleName("Default PM voice") + self.default_pm_voice_combo.setEditable(False) + pm_defaults_layout.addRow("Voice:", self.default_pm_voice_combo) + + # Then rate + self.default_pm_rate_slider = QSlider(Qt.Horizontal) + self.default_pm_rate_slider.setRange(-100, 100) + self.default_pm_rate_slider.setValue( + self.config_manager.config.accessibility.default_pm_rate + ) + self.default_pm_rate_label = QLabel(str(self.default_pm_rate_slider.value())) + self.default_pm_rate_slider.valueChanged.connect( + lambda v: self.default_pm_rate_label.setText(str(v)) + ) + rate_layout_pm = QHBoxLayout() + rate_layout_pm.addWidget(self.default_pm_rate_slider) + rate_layout_pm.addWidget(self.default_pm_rate_label) + pm_defaults_layout.addRow("Rate:", rate_layout_pm) + + # Test button for PM defaults + self.test_pm_button = QPushButton("Test PM Voice") + self.test_pm_button.clicked.connect(self.test_pm_voice) + pm_defaults_layout.addRow("", self.test_pm_button) + + pm_defaults_group.setLayout(pm_defaults_layout) + layout.addWidget(pm_defaults_group) + + # Test button + test_button = QPushButton("Test Speech") + test_button.clicked.connect(self.test_speech) + layout.addWidget(test_button) + + # Info label + info_label = QLabel( + "Note: StormIRC uses Speech Dispatcher for text-to-speech.\n" + "Make sure speech-dispatcher is installed and running." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + layout.addStretch() + self.setLayout(layout) + + # Populate voice and module dropdowns after UI is set up + self.populate_speech_options() + + def populate_speech_options(self): + """Populate all voice and module dropdowns from Speech Dispatcher.""" + speech = None + try: + logger.info("Populating speech options...") + from src.ui.speech import SpeechManager + logger.info("Creating SpeechManager...") + speech = SpeechManager() + logger.info("SpeechManager created successfully") + + # Get available voices and modules once + voices = speech.list_voices() + modules = speech.list_output_modules() + + # Populate global voice combo + self.speech_voice_combo.addItem("(System Default)", "") + if voices: + for voice in voices: + # Voice is a tuple: (name, language, variant) + if len(voice) >= 2: + display_name = f"{voice[0]} ({voice[1]})" + if len(voice) >= 3 and voice[2] and voice[2] != 'none': + display_name += f" - {voice[2]}" + self.speech_voice_combo.addItem(display_name, voice[0]) + + # Set current voice + current_voice = self.config_manager.config.accessibility.speech_voice + if current_voice: + index = self.speech_voice_combo.findData(current_voice) + if index >= 0: + self.speech_voice_combo.setCurrentIndex(index) + + # Populate global module combo + self.speech_module_combo.addItem("(System Default)", "") + if modules: + for module in modules: + self.speech_module_combo.addItem(module, module) + + # Set current module + current_module = self.config_manager.config.accessibility.speech_output_module + if current_module: + index = self.speech_module_combo.findData(current_module) + if index >= 0: + self.speech_module_combo.setCurrentIndex(index) + + # Populate channel and PM voice combos + for combo in [self.default_channel_voice_combo, self.default_pm_voice_combo]: + combo.addItem("(Use Global Voice)", "") + if voices: + for voice in voices: + if len(voice) >= 2: + display_name = f"{voice[0]} ({voice[1]})" + if len(voice) >= 3 and voice[2] and voice[2] != 'none': + display_name += f" - {voice[2]}" + combo.addItem(display_name, voice[0]) + + # Set current channel voice + channel_voice = self.config_manager.config.accessibility.default_channel_voice + if channel_voice: + index = self.default_channel_voice_combo.findData(channel_voice) + if index >= 0: + self.default_channel_voice_combo.setCurrentIndex(index) + + # Set current PM voice + pm_voice = self.config_manager.config.accessibility.default_pm_voice + if pm_voice: + index = self.default_pm_voice_combo.findData(pm_voice) + if index >= 0: + self.default_pm_voice_combo.setCurrentIndex(index) + + # Populate channel and PM module combos + for combo in [self.default_channel_module_combo, self.default_pm_module_combo]: + combo.addItem("(Use Global Default)", "") + if modules: + for module in modules: + combo.addItem(module, module) + + # Set current channel module + channel_module = self.config_manager.config.accessibility.default_channel_module + if channel_module: + index = self.default_channel_module_combo.findData(channel_module) + if index >= 0: + self.default_channel_module_combo.setCurrentIndex(index) + + # Set current PM module + pm_module = self.config_manager.config.accessibility.default_pm_module + if pm_module: + index = self.default_pm_module_combo.findData(pm_module) + if index >= 0: + self.default_pm_module_combo.setCurrentIndex(index) + + except Exception as e: + # If speech dispatcher isn't available, just add default options + logger.error(f"Error populating speech options: {e}", exc_info=True) + for combo in [self.speech_voice_combo, self.default_channel_voice_combo, self.default_pm_voice_combo]: + if combo.count() == 0: + combo.addItem("(Default)", "") + for combo in [self.speech_module_combo, self.default_channel_module_combo, self.default_pm_module_combo]: + if combo.count() == 0: + combo.addItem("(Default)", "") + finally: + # Clean up speech manager + logger.info("Closing SpeechManager...") + if speech: + try: + speech.close() + logger.info("SpeechManager closed successfully") + except Exception as e: + logger.error(f"Error closing SpeechManager: {e}", exc_info=True) + + def on_global_module_changed(self, index): + """Update voice list when output module changes.""" + # Get selected module + module_data = self.speech_module_combo.currentData() + if not module_data: + return + + logger.info(f"Module changed to: {module_data}, updating voice list...") + + # Save current voice selection + current_voice = self.speech_voice_combo.currentData() + + # Clear and repopulate voice list for the selected module + speech = None + try: + from src.ui.speech import SpeechManager + speech = SpeechManager() + + # Set the module first + if module_data: + speech.set_output_module(module_data) + + # Get voices for this module + voices = speech.list_voices() + + # Clear and repopulate + self.speech_voice_combo.clear() + self.speech_voice_combo.addItem("(System Default)", "") + + if voices: + for voice in voices: + if len(voice) >= 2: + display_name = f"{voice[0]} ({voice[1]})" + if len(voice) >= 3 and voice[2] and voice[2] != 'none': + display_name += f" - {voice[2]}" + self.speech_voice_combo.addItem(display_name, voice[0]) + + # Restore previous selection if still available + if current_voice: + index = self.speech_voice_combo.findData(current_voice) + if index >= 0: + self.speech_voice_combo.setCurrentIndex(index) + + except Exception as e: + logger.error(f"Error updating voice list: {e}", exc_info=True) + finally: + if speech: + try: + speech.close() + except Exception as e: + logger.error(f"Error closing SpeechManager: {e}", exc_info=True) + + def on_channel_module_changed(self, index): + """Update channel voice list when output module changes.""" + self._update_voice_for_module( + self.default_channel_module_combo, + self.default_channel_voice_combo, + self.config_manager.config.accessibility.default_channel_voice + ) + + def on_pm_module_changed(self, index): + """Update PM voice list when output module changes.""" + self._update_voice_for_module( + self.default_pm_module_combo, + self.default_pm_voice_combo, + self.config_manager.config.accessibility.default_pm_voice + ) + + def _update_voice_for_module(self, module_combo, voice_combo, current_voice_value): + """Helper to update a voice dropdown based on module selection.""" + module_data = module_combo.currentData() + if not module_data: + return + + logger.info(f"Module changed to: {module_data}, updating voice list...") + + # Save current voice selection + current_voice = voice_combo.currentData() + + speech = None + try: + from src.ui.speech import SpeechManager + speech = SpeechManager() + + # Set the module first + speech.set_output_module(module_data) + + # Get voices for this module + voices = speech.list_voices() + + # Clear and repopulate + voice_combo.clear() + voice_combo.addItem("(Use Global Voice)", "") + + if voices: + for voice in voices: + if len(voice) >= 2: + display_name = f"{voice[0]} ({voice[1]})" + if len(voice) >= 3 and voice[2] and voice[2] != 'none': + display_name += f" - {voice[2]}" + voice_combo.addItem(display_name, voice[0]) + + # Restore previous selection if still available + if current_voice: + index = voice_combo.findData(current_voice) + if index >= 0: + voice_combo.setCurrentIndex(index) + + except Exception as e: + logger.error(f"Error updating voice list: {e}", exc_info=True) + finally: + if speech: + try: + speech.close() + except Exception as e: + logger.error(f"Error closing SpeechManager: {e}", exc_info=True) + + def _apply_speech_settings(self, speech, module, voice, rate, pitch, volume): + """ + Helper method to apply speech settings in the correct order. + CRITICAL: Module must be set before voice! + """ + # Set output module first (if provided) + if module: + speech.set_output_module(module) + + # Set voice after module (if provided) + if voice: + speech.set_voice(voice) + + # Set rate, pitch, volume + speech.set_rate(rate) + speech.set_pitch(pitch) + speech.set_volume(volume) + + def _test_voice_settings(self, message, override_module=None, override_voice=None, override_rate=None): + """ + Helper method to test speech settings with optional overrides. + + Args: + message: Message to speak + override_module: Module to override global setting (None = use global, "" = no override) + override_voice: Voice to override global setting (None = use global, "" = no override) + override_rate: Rate to override global setting (None = use global, 0 = no override) + """ + speech = None + try: + from src.ui.speech import SpeechManager + speech = SpeechManager() + speech.set_enabled(True) + + # Get global settings + global_module = self.speech_module_combo.currentData() + global_voice = self.speech_voice_combo.currentData() + global_rate = self.speech_rate_slider.value() + global_pitch = self.speech_pitch_slider.value() + global_volume = self.speech_volume_slider.value() + + # Apply overrides (if provided and not empty) + module = override_module if override_module else global_module + voice = override_voice if override_voice else global_voice + rate = override_rate if override_rate is not None and override_rate != 0 else global_rate + + # Apply settings in correct order + self._apply_speech_settings(speech, module, voice, rate, global_pitch, global_volume) + + speech.speak(message) + except Exception as e: + QMessageBox.warning( + self, + "Speech Test Failed", + f"Could not test speech: {e}" + ) + finally: + # Clean up speech manager after a short delay to allow speech to finish + if speech: + from PySide6.QtCore import QTimer + QTimer.singleShot(3000, speech.close) + + def test_speech(self): + """Test global speech settings.""" + self._test_voice_settings("This is a test of the global speech settings.") + + def test_channel_voice(self): + """Test channel default voice settings.""" + channel_module = self.default_channel_module_combo.currentData() + channel_voice = self.default_channel_voice_combo.currentData() + channel_rate = self.default_channel_rate_slider.value() + + self._test_voice_settings( + "This is a test of the channel default voice settings.", + override_module=channel_module, + override_voice=channel_voice, + override_rate=channel_rate + ) + + def test_pm_voice(self): + """Test PM default voice settings.""" + pm_module = self.default_pm_module_combo.currentData() + pm_voice = self.default_pm_voice_combo.currentData() + pm_rate = self.default_pm_rate_slider.value() + + self._test_voice_settings( + "This is a test of the private message default voice settings.", + override_module=pm_module, + override_voice=pm_voice, + override_rate=pm_rate + ) + + def save_settings(self): + """Save settings.""" + # Get current accessibility config and update speech settings + accessibility_config = AccessibilityConfig( + enable_notifications=self.config_manager.config.accessibility.enable_notifications, + enable_sounds=self.config_manager.config.accessibility.enable_sounds, + announce_joins_parts=self.config_manager.config.accessibility.announce_joins_parts, + announce_nick_changes=self.config_manager.config.accessibility.announce_nick_changes, + mention_highlights=self.config_manager.config.accessibility.mention_highlights, + chat_history_lines=self.config_manager.config.accessibility.chat_history_lines, + font_size=self.config_manager.config.accessibility.font_size, + high_contrast=self.config_manager.config.accessibility.high_contrast, + screen_reader_announcements=self.config_manager.config.accessibility.screen_reader_announcements, + keyboard_shortcuts_enabled=self.config_manager.config.accessibility.keyboard_shortcuts_enabled, + sound_public_message=self.config_manager.config.accessibility.sound_public_message, + sound_private_message=self.config_manager.config.accessibility.sound_private_message, + sound_highlight=self.config_manager.config.accessibility.sound_highlight, + speech_enabled=self.speech_enabled_check.isChecked(), + speech_rate=self.speech_rate_slider.value(), + speech_pitch=self.speech_pitch_slider.value(), + speech_volume=self.speech_volume_slider.value(), + speech_voice=self.speech_voice_combo.currentData() or "", + speech_output_module=self.speech_module_combo.currentData() or "", + auto_read_messages=self.auto_read_check.isChecked(), + speech_read_own_messages=self.read_own_messages_check.isChecked(), + speech_read_timestamps=self.read_timestamps_check.isChecked(), + default_channel_voice=self.default_channel_voice_combo.currentData() or "", + default_channel_rate=self.default_channel_rate_slider.value(), + default_channel_module=self.default_channel_module_combo.currentData() or "", + default_pm_voice=self.default_pm_voice_combo.currentData() or "", + default_pm_rate=self.default_pm_rate_slider.value(), + default_pm_module=self.default_pm_module_combo.currentData() or "" + ) + self.config_manager.update_accessibility_config(accessibility_config) + + +class ServersTab(QWidget): + """Servers management tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Server list + list_label = QLabel("Configured Servers:") + layout.addWidget(list_label) + + self.server_list = QListWidget() + self.server_list.setAccessibleName("Server list") + self.refresh_server_list() + layout.addWidget(self.server_list) + + # Buttons + button_layout = QHBoxLayout() + + self.add_button = QPushButton("Add Server") + self.add_button.clicked.connect(self.add_server) + button_layout.addWidget(self.add_button) + + self.edit_button = QPushButton("Edit Server") + self.edit_button.clicked.connect(self.edit_server) + button_layout.addWidget(self.edit_button) + + self.remove_button = QPushButton("Remove Server") + self.remove_button.clicked.connect(self.remove_server) + button_layout.addWidget(self.remove_button) + + layout.addLayout(button_layout) + + self.setLayout(layout) + + def refresh_server_list(self): + """Refresh server list.""" + self.server_list.clear() + for server in self.config_manager.config.servers: + item_text = f"{server.name} ({server.host}:{server.port})" + self.server_list.addItem(item_text) + + def add_server(self): + """Add new server.""" + dialog = ServerEditDialog(self, None, self.config_manager) + if dialog.exec(): + self.refresh_server_list() + + def edit_server(self): + """Edit selected server.""" + current_row = self.server_list.currentRow() + if current_row < 0: + QMessageBox.warning(self, "No Selection", "Please select a server to edit.") + return + + server = self.config_manager.config.servers[current_row] + dialog = ServerEditDialog(self, server, self.config_manager) + if dialog.exec(): + self.refresh_server_list() + + def remove_server(self): + """Remove selected server.""" + current_row = self.server_list.currentRow() + if current_row < 0: + QMessageBox.warning(self, "No Selection", "Please select a server to remove.") + return + + server = self.config_manager.config.servers[current_row] + reply = QMessageBox.question( + self, + "Confirm Removal", + f"Are you sure you want to remove server '{server.name}'?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.config_manager.remove_server(server.name) + self.refresh_server_list() + + def save_settings(self): + """Save settings (already saved by dialogs).""" + pass + + +class ServerEditDialog(QDialog): + """Dialog for editing server configuration.""" + + def __init__(self, parent, server: ServerConfig, config_manager: ConfigManager): + super().__init__(parent) + self.server = server + self.config_manager = config_manager + self.is_new = server is None + + if self.is_new: + self.setWindowTitle("Add Server") + else: + self.setWindowTitle(f"Edit Server - {server.name}") + + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Form + form_layout = QFormLayout() + + # Server name + self.name_edit = QLineEdit() + self.name_edit.setAccessibleName("Server name") + if self.server: + self.name_edit.setText(self.server.name) + form_layout.addRow("Server Name:", self.name_edit) + + # Host + self.host_edit = QLineEdit() + self.host_edit.setAccessibleName("Server host") + if self.server: + self.host_edit.setText(self.server.host) + form_layout.addRow("Host:", self.host_edit) + + # Port + self.port_spin = QSpinBox() + self.port_spin.setRange(1, 65535) + self.port_spin.setValue(self.server.port if self.server else 6667) + self.port_spin.setAccessibleName("Server port") + form_layout.addRow("Port:", self.port_spin) + + # SSL + self.ssl_check = QCheckBox("Use SSL/TLS") + self.ssl_check.setChecked(self.server.use_ssl if self.server else False) + form_layout.addRow(self.ssl_check) + + # Nickname + self.nickname_edit = QLineEdit() + self.nickname_edit.setAccessibleName("Nickname") + if self.server: + self.nickname_edit.setText(self.server.nickname) + form_layout.addRow("Nickname:", self.nickname_edit) + + # Username + self.username_edit = QLineEdit() + self.username_edit.setAccessibleName("Username") + if self.server: + self.username_edit.setText(self.server.username) + form_layout.addRow("Username:", self.username_edit) + + # Real name + self.realname_edit = QLineEdit() + self.realname_edit.setAccessibleName("Real name") + if self.server: + self.realname_edit.setText(self.server.realname) + form_layout.addRow("Real Name:", self.realname_edit) + + # Server password + self.password_edit = QLineEdit() + self.password_edit.setEchoMode(QLineEdit.Password) + self.password_edit.setAccessibleName("Server password") + if self.server: + self.password_edit.setText(self.server.password) + form_layout.addRow("Server Password:", self.password_edit) + + # Auto-connect + self.auto_connect_check = QCheckBox("Auto-connect on startup") + self.auto_connect_check.setChecked(self.server.auto_connect if self.server else False) + form_layout.addRow(self.auto_connect_check) + + # Auto-join channels + self.autojoin_edit = QLineEdit() + self.autojoin_edit.setAccessibleName("Auto-join channels") + if self.server and self.server.auto_join_channels: + self.autojoin_edit.setText(",".join(self.server.auto_join_channels)) + self.autojoin_edit.setPlaceholderText("#channel1,#channel2") + form_layout.addRow("Auto-join Channels:", self.autojoin_edit) + + # SASL + sasl_group = QGroupBox("SASL Authentication") + sasl_layout = QFormLayout() + + self.use_sasl_check = QCheckBox("Enable SASL") + self.use_sasl_check.setChecked(self.server.use_sasl if self.server else False) + sasl_layout.addRow(self.use_sasl_check) + + self.sasl_username_edit = QLineEdit() + self.sasl_username_edit.setAccessibleName("SASL username") + if self.server: + self.sasl_username_edit.setText(self.server.sasl_username) + sasl_layout.addRow("SASL Username:", self.sasl_username_edit) + + self.sasl_password_edit = QLineEdit() + self.sasl_password_edit.setEchoMode(QLineEdit.Password) + self.sasl_password_edit.setAccessibleName("SASL password") + if self.server: + self.sasl_password_edit.setText(self.server.sasl_password) + sasl_layout.addRow("SASL Password:", self.sasl_password_edit) + + sasl_group.setLayout(sasl_layout) + + layout.addLayout(form_layout) + layout.addWidget(sasl_group) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.setLayout(layout) + + def accept(self): + """Accept and save.""" + # Validate + if not self.name_edit.text().strip(): + QMessageBox.warning(self, "Invalid Input", "Server name is required.") + return + + if not self.host_edit.text().strip(): + QMessageBox.warning(self, "Invalid Input", "Host is required.") + return + + if not self.nickname_edit.text().strip(): + QMessageBox.warning(self, "Invalid Input", "Nickname is required.") + return + + # Parse autojoin channels + autojoin_channels = [] + if self.autojoin_edit.text().strip(): + autojoin_channels = [ + ch.strip() for ch in self.autojoin_edit.text().split(',') + if ch.strip() + ] + + # Create/update server config + server_config = ServerConfig( + name=self.name_edit.text().strip(), + host=self.host_edit.text().strip(), + port=self.port_spin.value(), + use_ssl=self.ssl_check.isChecked(), + nickname=self.nickname_edit.text().strip(), + username=self.username_edit.text().strip(), + realname=self.realname_edit.text().strip(), + password=self.password_edit.text(), + auto_connect=self.auto_connect_check.isChecked(), + auto_join_channels=autojoin_channels, + use_sasl=self.use_sasl_check.isChecked(), + sasl_username=self.sasl_username_edit.text().strip(), + sasl_password=self.sasl_password_edit.text() + ) + + if self.is_new: + self.config_manager.add_server(server_config) + else: + self.config_manager.update_server(self.server.name, server_config) + + super().accept() + + +class HighlightsTab(QWidget): + """Highlights/Notifications tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Info label + info_label = QLabel( + "Highlight patterns are regular expressions that will trigger notifications.\n" + "Your nickname is automatically highlighted." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + # Pattern list + list_label = QLabel("Highlight Patterns:") + layout.addWidget(list_label) + + self.pattern_list = QListWidget() + self.pattern_list.setAccessibleName("Highlight patterns list") + self.refresh_pattern_list() + layout.addWidget(self.pattern_list) + + # Add pattern + add_layout = QHBoxLayout() + add_layout.addWidget(QLabel("New Pattern:")) + self.pattern_edit = QLineEdit() + self.pattern_edit.setAccessibleName("New pattern") + self.pattern_edit.setPlaceholderText("regex pattern") + add_layout.addWidget(self.pattern_edit) + + self.add_button = QPushButton("Add") + self.add_button.clicked.connect(self.add_pattern) + add_layout.addWidget(self.add_button) + + layout.addLayout(add_layout) + + # Remove button + self.remove_button = QPushButton("Remove Selected") + self.remove_button.clicked.connect(self.remove_pattern) + layout.addWidget(self.remove_button) + + layout.addStretch() + self.setLayout(layout) + + def refresh_pattern_list(self): + """Refresh pattern list.""" + self.pattern_list.clear() + for pattern in self.config_manager.config.highlight_patterns: + self.pattern_list.addItem(pattern) + + def add_pattern(self): + """Add pattern.""" + pattern = self.pattern_edit.text().strip() + if not pattern: + return + + self.config_manager.add_highlight_pattern(pattern) + self.refresh_pattern_list() + self.pattern_edit.clear() + + def remove_pattern(self): + """Remove selected pattern.""" + current_row = self.pattern_list.currentRow() + if current_row < 0: + return + + pattern = self.config_manager.config.highlight_patterns[current_row] + self.config_manager.remove_highlight_pattern(pattern) + self.refresh_pattern_list() + + def save_settings(self): + """Save settings (already saved).""" + pass + + +class ChannelSpeechTab(QWidget): + """Per-channel/PM speech settings tab.""" + + def __init__(self, config_manager: ConfigManager): + super().__init__() + self.config_manager = config_manager + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Instructions + info_label = QLabel( + "Configure voice, rate, and output module for specific channels and private messages.\n" + "Settings shown here override the defaults configured in the Speech tab." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + # Table of channels/PMs with custom settings + table_label = QLabel("Channels and PMs with custom speech settings:") + layout.addWidget(table_label) + + self.channel_table = QTableWidget() + self.channel_table.setColumnCount(5) + self.channel_table.setHorizontalHeaderLabels(["Server", "Channel/PM", "Voice", "Rate", "Module"]) + self.channel_table.setSelectionBehavior(QTableWidget.SelectRows) + self.channel_table.setAccessibleName("Channel speech settings table") + self.channel_table.horizontalHeader().setStretchLastSection(True) + self.channel_table.itemSelectionChanged.connect(self.on_selection_changed) + layout.addWidget(self.channel_table) + + # Buttons + button_layout = QHBoxLayout() + + self.reset_button = QPushButton("Reset to Default") + self.reset_button.setEnabled(False) + self.reset_button.clicked.connect(self.reset_selected) + button_layout.addWidget(self.reset_button) + + self.refresh_button = QPushButton("Refresh List") + self.refresh_button.clicked.connect(self.refresh_table) + button_layout.addWidget(self.refresh_button) + + button_layout.addStretch() + + layout.addLayout(button_layout) + + # Note about configuring new channels + note_label = QLabel( + "Note: To configure speech settings for a channel or PM, use the right-click context menu " + "in the main window's channel list." + ) + note_label.setWordWrap(True) + note_label.setStyleSheet("font-style: italic; color: #666;") + layout.addWidget(note_label) + + self.setLayout(layout) + self.refresh_table() + + def refresh_table(self): + """Refresh the channel settings table.""" + self.channel_table.setRowCount(0) + + settings_dict = self.config_manager.config.channel_speech_settings + for key, settings in settings_dict.items(): + # Parse server:target key + parts = key.split(':', 1) + if len(parts) != 2: + continue + + server_name, target = parts + + row = self.channel_table.rowCount() + self.channel_table.insertRow(row) + + # Server + server_item = QTableWidgetItem(server_name) + server_item.setFlags(server_item.flags() & ~Qt.ItemIsEditable) + self.channel_table.setItem(row, 0, server_item) + + # Channel/PM + target_item = QTableWidgetItem(target) + target_item.setFlags(target_item.flags() & ~Qt.ItemIsEditable) + self.channel_table.setItem(row, 1, target_item) + + # Voice + voice_item = QTableWidgetItem(settings.get('voice', '')) + voice_item.setFlags(voice_item.flags() & ~Qt.ItemIsEditable) + self.channel_table.setItem(row, 2, voice_item) + + # Rate + rate_item = QTableWidgetItem(str(settings.get('rate', 0))) + rate_item.setFlags(rate_item.flags() & ~Qt.ItemIsEditable) + self.channel_table.setItem(row, 3, rate_item) + + # Module + module_item = QTableWidgetItem(settings.get('output_module', '')) + module_item.setFlags(module_item.flags() & ~Qt.ItemIsEditable) + self.channel_table.setItem(row, 4, module_item) + + def on_selection_changed(self): + """Handle selection change.""" + self.reset_button.setEnabled(len(self.channel_table.selectedItems()) > 0) + + def reset_selected(self): + """Reset selected channel to default settings.""" + current_row = self.channel_table.currentRow() + if current_row < 0: + return + + server_name = self.channel_table.item(current_row, 0).text() + target = self.channel_table.item(current_row, 1).text() + + # Confirm + reply = QMessageBox.question( + self, + "Reset to Default", + f"Reset speech settings for {target} on {server_name} to default?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.config_manager.remove_channel_speech_settings(server_name, target) + self.refresh_table() + + def save_settings(self): + """Save settings (already saved).""" + pass + + +class SettingsDialog(QDialog): + """Main settings dialog.""" + + settings_changed = Signal() + + def __init__(self, parent, config_manager: ConfigManager): + super().__init__(parent) + self.config_manager = config_manager + self.setWindowTitle("StormIRC Settings") + self.setMinimumSize(600, 500) + self.init_ui() + + def init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + # Tab widget + self.tabs = QTabWidget() + self.tabs.setAccessibleName("Settings tabs") + + # Create tabs + self.general_tab = GeneralSettingsTab(self.config_manager) + self.accessibility_tab = AccessibilitySettingsTab(self.config_manager) + self.speech_tab = SpeechSettingsTab(self.config_manager) + self.channel_speech_tab = ChannelSpeechTab(self.config_manager) + self.servers_tab = ServersTab(self.config_manager) + self.highlights_tab = HighlightsTab(self.config_manager) + + # Add tabs + self.tabs.addTab(self.general_tab, "General") + self.tabs.addTab(self.accessibility_tab, "Accessibility") + self.tabs.addTab(self.speech_tab, "Speech") + self.tabs.addTab(self.channel_speech_tab, "Channel Speech") + self.tabs.addTab(self.servers_tab, "Servers") + self.tabs.addTab(self.highlights_tab, "Highlights") + + layout.addWidget(self.tabs) + + # Buttons + button_layout = QHBoxLayout() + + self.export_button = QPushButton("Export Config") + self.export_button.clicked.connect(self.export_config) + button_layout.addWidget(self.export_button) + + self.import_button = QPushButton("Import Config") + self.import_button.clicked.connect(self.import_config) + button_layout.addWidget(self.import_button) + + button_layout.addStretch() + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Apply + ) + buttons.button(QDialogButtonBox.Ok).clicked.connect(self.save_and_close) + buttons.button(QDialogButtonBox.Apply).clicked.connect(self.apply_settings) + buttons.button(QDialogButtonBox.Cancel).clicked.connect(self.reject) + button_layout.addWidget(buttons) + + layout.addLayout(button_layout) + + self.setLayout(layout) + + def apply_settings(self): + """Apply settings without closing.""" + self.general_tab.save_settings() + self.accessibility_tab.save_settings() + self.speech_tab.save_settings() + self.channel_speech_tab.save_settings() + self.servers_tab.save_settings() + self.highlights_tab.save_settings() + self.settings_changed.emit() + QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.") + + def save_and_close(self): + """Save settings and close.""" + self.apply_settings() + self.accept() + + def export_config(self): + """Export configuration.""" + file_path, _ = QFileDialog.getSaveFileName( + self, + "Export Configuration", + str(Path.home() / "stormirc_config.json"), + "JSON Files (*.json)" + ) + + if file_path: + if self.config_manager.export_config(file_path): + QMessageBox.information( + self, + "Export Successful", + f"Configuration exported to {file_path}" + ) + else: + QMessageBox.critical( + self, + "Export Failed", + "Failed to export configuration." + ) + + def import_config(self): + """Import configuration.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Import Configuration", + str(Path.home()), + "JSON Files (*.json)" + ) + + if file_path: + reply = QMessageBox.question( + self, + "Confirm Import", + "This will replace your current configuration. Continue?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + if self.config_manager.import_config(file_path): + QMessageBox.information( + self, + "Import Successful", + "Configuration imported successfully. Please restart StormIRC." + ) + self.accept() + else: + QMessageBox.critical( + self, + "Import Failed", + "Failed to import configuration." + ) diff --git a/src/ui/sound.py b/src/ui/sound.py new file mode 100644 index 0000000..01f574a --- /dev/null +++ b/src/ui/sound.py @@ -0,0 +1,132 @@ +"""Sound notification system for StormIRC.""" + +import logging +from pathlib import Path +from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput +from PySide6.QtCore import QUrl + +logger = logging.getLogger(__name__) + + +class SoundPlayer: + """Handles sound notifications for IRC events.""" + + def __init__(self, config_manager=None): + """Initialize sound player. + + Args: + config_manager: ConfigManager instance for reading sound settings + """ + # Get sounds directory relative to this file + self.sounds_dir = Path(__file__).parent.parent.parent / "sounds" + logger.info(f"Sound directory: {self.sounds_dir}") + + self.config_manager = config_manager + + # Initialize sound effects + self.sounds = {} + self._load_sounds() + + def _load_sounds(self): + """Load sound files.""" + sound_files = { + 'public_message': 'public_message.wav', + 'private_message': 'private_message.wav', + 'highlight': 'highlight.wav' + } + + for sound_name, filename in sound_files.items(): + sound_path = self.sounds_dir / filename + if sound_path.exists(): + # Create media player and audio output for each sound + player = QMediaPlayer() + audio_output = QAudioOutput() + player.setAudioOutput(audio_output) + player.setSource(QUrl.fromLocalFile(str(sound_path))) + audio_output.setVolume(1.0) + self.sounds[sound_name] = (player, audio_output) + logger.info(f"Loaded sound: {sound_name} from {sound_path}") + else: + logger.warning(f"Sound file not found: {sound_path}") + + def _is_sound_enabled(self, sound_type): + """Check if a specific sound type is enabled in settings. + + Args: + sound_type: One of 'public_message', 'private_message', 'highlight' + + Returns: + True if sound should play, False otherwise + """ + if not self.config_manager: + logger.debug(f"No config manager, defaulting {sound_type} to enabled") + return True # Default to enabled if no config manager + + accessibility_config = self.config_manager.get_accessibility_config() + + # Map sound types to config attributes + sound_settings = { + 'public_message': accessibility_config.sound_public_message, + 'private_message': accessibility_config.sound_private_message, + 'highlight': accessibility_config.sound_highlight + } + + enabled = sound_settings.get(sound_type, True) + logger.debug(f"Sound {sound_type} enabled: {enabled}") + return enabled + + def play_public_message(self): + """Play sound for public channel messages.""" + logger.debug("play_public_message called") + if self._is_sound_enabled('public_message'): + self._play_sound('public_message') + else: + logger.debug("Public message sound disabled in settings") + + def play_private_message(self): + """Play sound for private messages.""" + logger.debug("play_private_message called") + if self._is_sound_enabled('private_message'): + self._play_sound('private_message') + else: + logger.debug("Private message sound disabled in settings") + + def play_highlight(self): + """Play sound for nickname highlights/mentions.""" + logger.debug("play_highlight called") + if self._is_sound_enabled('highlight'): + self._play_sound('highlight') + else: + logger.debug("Highlight sound disabled in settings") + + def _play_sound(self, sound_name): + """Play a specific sound.""" + if sound_name in self.sounds: + try: + player, audio_output = self.sounds[sound_name] + player.stop() # Stop if already playing + player.setPosition(0) # Rewind to beginning + player.play() + logger.debug(f"Playing sound: {sound_name}") + except Exception as e: + logger.error(f"Failed to play sound {sound_name}: {e}") + else: + logger.warning(f"Sound not loaded: {sound_name}") + + def is_highlight(self, message, nickname): + """Check if a message contains a highlight/mention of the nickname. + + Args: + message: The message text to check + nickname: The user's nickname to look for + + Returns: + True if the nickname appears in the message (case-insensitive) + """ + if not message or not nickname: + return False + + # Case-insensitive check for nickname anywhere in message + return nickname.lower() in message.lower() + + diff --git a/src/ui/speech.py b/src/ui/speech.py new file mode 100644 index 0000000..abb0ca5 --- /dev/null +++ b/src/ui/speech.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Speech Dispatcher integration for self-voicing functionality. +Provides text-to-speech output for IRC messages and events. +""" + +import logging +import threading +from typing import Optional, Dict + +try: + import speechd + SPEECHD_AVAILABLE = True +except ImportError: + SPEECHD_AVAILABLE = False + logging.warning("Speech Dispatcher not available. Install python3-speechd for self-voicing support.") + + +class SpeechManager: + """Manages text-to-speech output via Speech Dispatcher.""" + + def __init__(self, app_name: str = "StormIRC"): + """ + Initialize the speech manager. + + Args: + app_name: Application name for Speech Dispatcher connection + """ + self.app_name = app_name + self.client: Optional[speechd.SSIPClient] = None + self.enabled = False + self.speechLock = threading.Lock() + self.uiSpeechThread = None + + # Per-window speech settings + self.window_speech_settings = {} # Per-window speech enable/disable + self.window_voice_settings: Dict[str, str] = {} # Per-window voice + self.window_module_settings: Dict[str, str] = {} # Per-window output module + self.window_rate_settings: Dict[str, int] = {} # Per-window rate + + # Global default settings + self.default_voice = None + self.default_module = None + self.default_rate = 0 + self.default_pitch = 0 + self.default_volume = 0 + + if SPEECHD_AVAILABLE: + self._connect() + + def _connect(self) -> bool: + """ + Connect to Speech Dispatcher. + + Returns: + True if connection successful, False otherwise + """ + try: + self.client = speechd.SSIPClient(self.app_name) + logging.info("Connected to Speech Dispatcher") + return True + except Exception as e: + logging.error(f"Failed to connect to Speech Dispatcher: {e}") + self.client = None + return False + + def set_enabled(self, enabled: bool): + """ + Enable or disable speech globally. + + Args: + enabled: True to enable speech, False to disable + """ + self.enabled = enabled + logging.debug(f"Global speech {'enabled' if enabled else 'disabled'}") + + def is_enabled(self) -> bool: + """ + Check if speech is globally enabled. + + Returns: + True if speech is enabled and available + """ + return self.enabled and SPEECHD_AVAILABLE and self.client is not None + + def set_window_speech(self, window_id: str, enabled: bool): + """ + Enable or disable speech for a specific window/channel. + + Args: + window_id: Window identifier (e.g., channel name or server) + enabled: True to enable speech for this window, False to disable + """ + self.window_speech_settings[window_id] = enabled + logging.debug(f"Speech for window '{window_id}' {'enabled' if enabled else 'disabled'}") + + def is_window_speech_enabled(self, window_id: str) -> bool: + """ + Check if speech is enabled for a specific window. + + Args: + window_id: Window identifier + + Returns: + True if speech is enabled for this window (defaults to True if not set) + """ + return self.window_speech_settings.get(window_id, True) + + def toggle_window_speech(self, window_id: str) -> bool: + """ + Toggle speech for a specific window. + + Args: + window_id: Window identifier + + Returns: + New state (True if now enabled, False if now disabled) + """ + current = self.is_window_speech_enabled(window_id) + new_state = not current + self.set_window_speech(window_id, new_state) + return new_state + + def speak(self, text: str, window_id: Optional[str] = None, priority: str = "message", interrupt: bool = True): + """ + Speak text if speech is enabled (globally and for the window). + + Args: + text: Text to speak + window_id: Optional window identifier to check window-specific settings + priority: Speech priority - "important", "message", or "text" + interrupt: If True, stop current speech first (default: True) + """ + if not self.is_enabled(): + return + + # Check window-specific settings if window_id provided + if window_id and not self.is_window_speech_enabled(window_id): + return + + if not text or not text.strip() or not self.client: + return + + # Safety: Wait for previous UI speech thread to finish if still running + if self.uiSpeechThread and self.uiSpeechThread.is_alive(): + self.uiSpeechThread.join(timeout=0.1) + + def speak_thread(): + with self.speechLock: + try: + if interrupt: + self.client.stop() + + # Apply window-specific settings if provided, otherwise use defaults + # CRITICAL: Set output module BEFORE voice, because changing module resets voice + # Set output module + if window_id and window_id in self.window_module_settings: + self.client.set_output_module(self.window_module_settings[window_id]) + elif self.default_module: + self.client.set_output_module(self.default_module) + + # Set voice (must come AFTER module) + if window_id and window_id in self.window_voice_settings: + self.client.set_synthesis_voice(self.window_voice_settings[window_id]) + elif self.default_voice: + self.client.set_synthesis_voice(self.default_voice) + + # Set rate (always apply, even if 0) + if window_id and window_id in self.window_rate_settings: + self.client.set_rate(self.window_rate_settings[window_id]) + else: + self.client.set_rate(self.default_rate) + + # Set pitch (always apply) + self.client.set_pitch(self.default_pitch) + + # Set volume (always apply) + self.client.set_volume(self.default_volume) + + # Map priority to Speech Dispatcher priority + priority_map = { + "important": speechd.Priority.IMPORTANT, + "message": speechd.Priority.MESSAGE, + "text": speechd.Priority.TEXT + } + spd_priority = priority_map.get(priority, speechd.Priority.MESSAGE) + + self.client.set_priority(spd_priority) + self.client.speak(str(text)) + logging.debug(f"Speaking: {text[:50]}...") + except Exception as e: + logging.error(f"Failed to speak text: {e}") + + self.uiSpeechThread = threading.Thread(target=speak_thread) + self.uiSpeechThread.daemon = True + self.uiSpeechThread.start() + + def cancel(self): + """Cancel current speech output.""" + if self.client: + try: + self.client.stop() + except Exception as e: + logging.error(f"Failed to cancel speech: {e}") + + def stop(self): + """Stop current speech (alias for cancel).""" + self.cancel() + + def set_rate(self, rate: int, window_id: Optional[str] = None): + """ + Set speech rate globally or for a specific window. + + Args: + rate: Speech rate from -100 (slowest) to 100 (fastest), 0 is default + window_id: Optional window identifier. If None, sets global default. + """ + rate = max(-100, min(100, rate)) + + if window_id: + self.window_rate_settings[window_id] = rate + else: + self.default_rate = rate + if self.client: + try: + self.client.set_rate(rate) + except Exception as e: + logging.error(f"Failed to set speech rate: {e}") + + def set_pitch(self, pitch: int): + """ + Set speech pitch globally. + + Args: + pitch: Speech pitch from -100 (lowest) to 100 (highest), 0 is default + """ + pitch = max(-100, min(100, pitch)) + self.default_pitch = pitch + + if self.client: + try: + self.client.set_pitch(pitch) + except Exception as e: + logging.error(f"Failed to set speech pitch: {e}") + + def set_volume(self, volume: int): + """ + Set speech volume globally. + + Args: + volume: Speech volume from -100 (quietest) to 100 (loudest), 0 is default + """ + volume = max(-100, min(100, volume)) + self.default_volume = volume + + if self.client: + try: + self.client.set_volume(volume) + except Exception as e: + logging.error(f"Failed to set speech volume: {e}") + + def set_voice(self, voice_name: str, window_id: Optional[str] = None): + """ + Set the voice/synthesizer to use globally or for a specific window. + + Args: + voice_name: Name of the voice/synthesizer + window_id: Optional window identifier. If None, sets global default. + """ + if window_id: + self.window_voice_settings[window_id] = voice_name + else: + self.default_voice = voice_name + if self.client: + try: + self.client.set_synthesis_voice(voice_name) + except Exception as e: + logging.error(f"Failed to set voice: {e}") + + def set_output_module(self, module_name: str, window_id: Optional[str] = None): + """ + Set the output module (speech synthesizer) globally or for a specific window. + + Args: + module_name: Name of the output module (e.g., 'espeak-ng', 'festival') + window_id: Optional window identifier. If None, sets global default. + """ + if window_id: + self.window_module_settings[window_id] = module_name + else: + self.default_module = module_name + if self.client: + try: + self.client.set_output_module(module_name) + except Exception as e: + logging.error(f"Failed to set output module: {e}") + + def list_voices(self) -> list: + """ + Get list of available voices. + + Returns: + List of voice tuples (name, language, variant), empty if not available + """ + if self.client: + try: + voices = self.client.list_synthesis_voices() + return list(voices) if voices else [] + except Exception as e: + logging.error(f"Failed to list voices: {e}") + return [] + + def list_output_modules(self) -> list: + """ + List available output modules (speech synthesizers). + + Returns: + List of output module names (e.g., 'espeak-ng', 'festival', 'flite') + """ + if self.client: + try: + modules = self.client.list_output_modules() + return list(modules) if modules else [] + except Exception as e: + logging.error(f"Failed to list output modules: {e}") + return [] + + def get_window_voice(self, window_id: str) -> Optional[str]: + """Get the voice set for a specific window.""" + return self.window_voice_settings.get(window_id) + + def get_window_module(self, window_id: str) -> Optional[str]: + """Get the output module set for a specific window.""" + return self.window_module_settings.get(window_id) + + def get_window_rate(self, window_id: str) -> Optional[int]: + """Get the speech rate set for a specific window.""" + return self.window_rate_settings.get(window_id) + + def clear_window_settings(self, window_id: str): + """Clear all custom settings for a specific window.""" + self.window_voice_settings.pop(window_id, None) + self.window_module_settings.pop(window_id, None) + self.window_rate_settings.pop(window_id, None) + + def close(self): + """Close connection to Speech Dispatcher.""" + if self.client: + try: + self.client.close() + logging.info("Disconnected from Speech Dispatcher") + except Exception as e: + logging.error(f"Error closing Speech Dispatcher connection: {e}") + finally: + self.client = None + + def is_available(self) -> bool: + """Check if speech-dispatcher is available.""" + return SPEECHD_AVAILABLE and self.client is not None diff --git a/src/ui/ui_utils.py b/src/ui/ui_utils.py new file mode 100644 index 0000000..1933a7c --- /dev/null +++ b/src/ui/ui_utils.py @@ -0,0 +1,68 @@ +"""Shared UI utilities for StormIRC. + +This module contains common utility functions used across UI components +to maintain DRY principles and consistency. +""" + +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.config.settings import UIConfig, ChannelSpeechSettings + from src.ui.speech import SpeechManager + +logger = logging.getLogger(__name__) + + +# UI Constants +DEFAULT_WINDOW_WIDTH = 800 +DEFAULT_WINDOW_HEIGHT = 600 +DEFAULT_PM_WINDOW_WIDTH = 600 +DEFAULT_PM_WINDOW_HEIGHT = 400 +DEFAULT_TOPIC_BAR_HEIGHT = 60 +MESSAGE_ENTRY_MAX_HEIGHT = 60 # Keep main window message entry compact +PM_MESSAGE_ENTRY_MAX_HEIGHT = 100 # PM window has slightly more space +MIN_WINDOW_WIDTH = 400 +MIN_WINDOW_HEIGHT = 300 + + +def format_message_with_timestamp(message: str, ui_config: 'UIConfig') -> str: + """Format a message with an optional timestamp. + + Args: + message: The message text to format + ui_config: UI configuration containing timestamp preferences + + Returns: + Formatted message string with timestamp if enabled + """ + if ui_config.show_timestamps: + timestamp = datetime.now().strftime(ui_config.timestamp_format) + return f"{message} {timestamp}" + return message + + +def apply_speech_settings(speech_manager: 'SpeechManager', + settings: 'ChannelSpeechSettings', + window_id: str) -> None: + """Apply channel/PM speech settings to the speech manager. + + This consolidates the common pattern of applying per-channel/PM + speech configuration (voice, rate, output module) to avoid duplication. + + Args: + speech_manager: The SpeechManager instance to configure + settings: Channel-specific speech settings + window_id: Unique identifier for the window (channel name or PM nick) + """ + if settings.voice: + speech_manager.set_voice(settings.voice, window_id=window_id) + if settings.rate != 0: + speech_manager.set_rate(settings.rate, window_id=window_id) + if settings.output_module: + speech_manager.set_output_module(settings.output_module, window_id=window_id) + + logger.debug(f"Applied speech settings for window '{window_id}': " + f"voice={settings.voice}, rate={settings.rate}, " + f"module={settings.output_module}") diff --git a/stormirc b/stormirc new file mode 100755 index 0000000..8b5ffac --- /dev/null +++ b/stormirc @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""StormIRC - Accessible IRC Client.""" + +import sys +import argparse +import logging +from pathlib import Path + +# Add the project root to the path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from src.ui.main_window import MainWindow +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import Qt + + +def setup_logging(debug=False): + """Set up logging configuration.""" + if debug: + log_file = project_root / "stormirc.log" + logging.basicConfig( + level=logging.DEBUG, + format='[%(levelname)s] %(name)s: %(message)s [%(asctime)s]', + datefmt='%a %b %d %I:%M:%S %p %Z %Y', + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler(sys.stdout) + ] + ) + logging.info(f"Debug logging enabled, writing to {log_file}") + else: + logging.basicConfig( + level=logging.WARNING, + format='%(levelname)s: %(message)s' + ) + + +def main(): + """Main entry point for StormIRC.""" + parser = argparse.ArgumentParser(description="StormIRC - Accessible IRC Client") + parser.add_argument('-d', '--debug', action='store_true', + help='Enable debug logging to stormirc.log') + args = parser.parse_args() + + setup_logging(args.debug) + + # Enable high DPI scaling + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + app = QApplication(sys.argv) + app.setApplicationName("StormIRC") + app.setOrganizationName("Stormux") + + # Create and show main window + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() +