diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..9980fea --- /dev/null +++ b/build.ps1 @@ -0,0 +1,18 @@ +Write-Host "Building application with PyInstaller..." -ForegroundColor Cyan + +# Activate venv +& "$PSScriptRoot\venv\Scripts\Activate.ps1" + +pyinstaller ` + --onefile ` + --noconfirm ` + --windowed ` + --hidden-import wx._xml ` + --add-data "FiraCode-Regular.ttf;." ` + --add-data "FiraCode-SemiBold.ttf;." ` + --add-data "venv\Lib\site-packages\irc\codes.txt;irc" ` + --add-data "icon.ico;." ` + --icon "icon.ico" ` + "src/main.py" + +Write-Host "Build complete!" -ForegroundColor Green diff --git a/build.sh b/build.sh deleted file mode 100644 index 2c9e968..0000000 --- a/build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -pyinstaller \ - --onefile \ - --noconfirm \ - --add-data "FiraCode-Regular.ttf:." \ - --add-data "FiraCode-SemiBold.ttf:." \ - --add-data "$(python -c 'import irc, os; print(os.path.dirname(irc.__file__))'):irc" \ - --hidden-import=irc.client \ - --hidden-import=irc.connection \ - --hidden-import=irc.events \ - --hidden-import=irc.strings \ - --hidden-import=wx \ - --hidden-import=wx.lib.mixins.listctrl \ - --hidden-import=wx.lib.mixins.treemixin \ - --hidden-import=wx.lib.mixins.inspection \ - --hidden-import=psutil \ - --hidden-import=queue \ - --hidden-import=logging.handlers \ - main.py \ No newline at end of file diff --git a/icon.ico b/icon.ico index d7b6ba6..0cf7dee 100644 Binary files a/icon.ico and b/icon.ico differ diff --git a/src/AboutDialog.py b/src/AboutDialog.py new file mode 100644 index 0000000..9221763 --- /dev/null +++ b/src/AboutDialog.py @@ -0,0 +1,57 @@ +import wx +import sys +import os + +def get_resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + + +class AboutDialog(wx.Dialog): + def __init__(self, parent): + super().__init__(parent, title="About wxIRC Client", style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + sizer = wx.BoxSizer(wx.VERTICAL) + + icon_path = get_resource_path("icon.ico") + icon_bitmap = wx.Bitmap(icon_path, wx.BITMAP_TYPE_ICO) + icon_ctrl = wx.StaticBitmap(self, bitmap=icon_bitmap) + + # Add the icon to the sizer + sizer.Add(icon_ctrl, 0, wx.ALL | wx.ALIGN_CENTER, 10) + + # Application info + info_text = wx.StaticText(self, label="wxIRC Client") + info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) + info_text.SetFont(info_font) + + version_text = wx.StaticText(self, label="V 1.1.1.0") + version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + version_text.SetFont(version_font) + + contrubutors_text = wx.StaticText(self, label="MiT License") + contrubutors_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + contrubutors_text.SetFont(contrubutors_font) + + # Add info to sizer + sizer.Add(info_text, 0, wx.ALL | wx.ALIGN_CENTER, 5) + sizer.Add(version_text, 0, wx.ALL | wx.ALIGN_CENTER, 5) + sizer.Add(contrubutors_text, 0, wx.ALL | wx.ALIGN_CENTER, 5) + + # OK button + ok_btn = wx.Button(self, wx.ID_OK, "OK") + ok_btn.SetDefault() + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + btn_sizer.Add(ok_btn, 0, wx.ALIGN_CENTER | wx.ALL, 10) + sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER) + + self.SetSizer(sizer) + self.Fit() + self.Centre() + diff --git a/src/FiraCode-Regular.ttf b/src/FiraCode-Regular.ttf new file mode 100644 index 0000000..bd73685 Binary files /dev/null and b/src/FiraCode-Regular.ttf differ diff --git a/src/FiraCode-SemiBold.ttf b/src/FiraCode-SemiBold.ttf new file mode 100644 index 0000000..d8dcef6 Binary files /dev/null and b/src/FiraCode-SemiBold.ttf differ diff --git a/src/IRCPanel.py b/src/IRCPanel.py new file mode 100644 index 0000000..c78ee35 --- /dev/null +++ b/src/IRCPanel.py @@ -0,0 +1,373 @@ +import wx +import threading +import logging +from SearchDialog import SearchDialog +import traceback + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class IRCPanel(wx.Panel): + def __init__(self, parent, main_frame): + super().__init__(parent) + self.parent = parent + self.main_frame = main_frame + self.messages = [] + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Use a better font for chat with white theme + self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL) + + # White theme colors + self.text_ctrl.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background + self.text_ctrl.SetForegroundColour(wx.Colour(0, 0, 0)) # Black text + + # Load appropriate font + self.font = self.load_system_font() + self.text_ctrl.SetFont(self.font) + + sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0) + + input_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) + self.input_ctrl.SetHint("Type message here …") + self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send) + self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down) + + send_btn = wx.Button(self, label="Send") + send_btn.SetToolTip("Send message (Enter)") + send_btn.Bind(wx.EVT_BUTTON, self.on_send) + + input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2) + input_sizer.Add(send_btn, 0, wx.ALL, 2) + + sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0) + + self.SetSizer(sizer) + self.target = None + self.history = [] + self.history_pos = -1 + + # Search state + self.search_text = "" + self.search_positions = [] + self.current_search_index = -1 + + # Bind Ctrl+F for search + self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down) + accel_tbl = wx.AcceleratorTable([(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)]) + self.SetAcceleratorTable(accel_tbl) + self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND) + + def load_system_font(self): + """Load appropriate system font with high DPI support""" + try: + # Get system DPI scale factor + dc = wx.ClientDC(self) + dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI + + # Calculate base font size based on DPI + base_size = 10 + if dpi_scale > 1.5: + font_size = int(base_size * 1.5) # 150% scaling + elif dpi_scale > 1.25: + font_size = int(base_size * 1.25) # 125% scaling + else: + font_size = base_size + + # Try system fonts in order of preference + font_families = [ + (wx.FONTFAMILY_TELETYPE, "Consolas"), + (wx.FONTFAMILY_TELETYPE, "Courier New"), + (wx.FONTFAMILY_TELETYPE, "Monaco"), + (wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"), + (wx.FONTFAMILY_TELETYPE, "Liberation Mono"), + ] + + for family, face_name in font_families: + font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name) + if font.IsOk(): + logger.info(f"Using font: {face_name} at {font_size}pt") + return font + + # Fallback to default monospace + font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + logger.info("Using system monospace font as fallback") + return font + + except Exception as e: + logger.error(f"Error loading system font: {e}") + # Ultimate fallback + return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + + def set_target(self, target): + self.target = target + + def add_message(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): + """Thread-safe message addition with username coloring""" + try: + # Use CallAfter for thread safety + if wx.IsMainThread(): + self._add_message_safe(message, username, username_color, message_color, bold, italic, underline) + else: + wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline) + except Exception as e: + logger.error(f"Error in add_message: {e}") + + def _add_message_safe(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): + """Actually add message - must be called from main thread""" + try: + self.messages.append(message) + + # Save current position for formatting + start_pos = self.text_ctrl.GetLastPosition() + + if username and username_color: + # Add username with its color + attr = wx.TextAttr() + attr.SetTextColour(username_color) + if bold: + attr.SetFontWeight(wx.FONTWEIGHT_BOLD) + if italic: + attr.SetFontStyle(wx.FONTSTYLE_ITALIC) + if underline: + attr.SetFontUnderlined(True) + attr.SetFont(self.font) + self.text_ctrl.SetDefaultStyle(attr) + self.text_ctrl.AppendText(username) + + # Add the rest of the message with message color + attr = wx.TextAttr() + if message_color: + attr.SetTextColour(message_color) + else: + attr.SetTextColour(wx.Colour(0, 0, 0)) # Black text for white theme + + attr.SetFont(self.font) + self.text_ctrl.SetDefaultStyle(attr) + + # Append the message (without username if we already added it) + if username and username_color: + # Find the message part after username + message_text = message[message.find(username) + len(username):] + self.text_ctrl.AppendText(message_text + "\n") + else: + self.text_ctrl.AppendText(message + "\n") + + # Auto-scroll to bottom + self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) + except Exception as e: + logger.error(f"Error adding message safely: {e}") + + def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): + """Add a formatted message with colored username""" + try: + if is_action: + message = f"{timestamp}* {username} {content}" + self.add_message(message, f"* {username}", username_color, wx.Colour(128, 0, 128), italic=True) # Dark purple for actions + else: + message = f"{timestamp}<{username}> {content}" + self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text + except Exception as e: + logger.error(f"Error adding formatted message: {e}") + + def add_system_message(self, message, color=None, bold=False): + """Add system message without username coloring""" + try: + if color is None: + color = wx.Colour(0, 0, 128) # Dark blue for system messages + self.add_message(message, None, None, color, bold, False, False) + except Exception as e: + logger.error(f"Error adding system message: {e}") + + def on_text_key_down(self, event): + """Handle key events in the text control""" + keycode = event.GetKeyCode() + if event.ControlDown() and keycode == ord('F'): + self.on_search(event) + else: + event.Skip() + + def on_search(self, event): + """Open search dialog""" + try: + dlg = SearchDialog(self) + dlg.ShowModal() + dlg.Destroy() + except Exception as e: + logger.error(f"Error in search: {e}") + + def perform_search(self, search_text, case_sensitive=False, whole_word=False): + """Perform text search in the chat history""" + try: + self.search_text = search_text + self.search_positions = [] + self.current_search_index = -1 + + # Get all text + full_text = self.text_ctrl.GetValue() + if not full_text or not search_text: + return + + # Prepare search parameters + search_flags = 0 + if not case_sensitive: + # For manual search, we'll handle case sensitivity ourselves + search_text_lower = search_text.lower() + full_text_lower = full_text.lower() + + # Manual search implementation since wx.TextCtrl doesn't have FindText + pos = 0 + while pos < len(full_text): + if case_sensitive: + # Case sensitive search + found_pos = full_text.find(search_text, pos) + else: + # Case insensitive search + found_pos = full_text_lower.find(search_text_lower, pos) + + if found_pos == -1: + break + + # For whole word search, verify boundaries + if whole_word: + # Check if it's a whole word + is_word_start = (found_pos == 0 or not full_text[found_pos-1].isalnum()) + is_word_end = (found_pos + len(search_text) >= len(full_text) or + not full_text[found_pos + len(search_text)].isalnum()) + + if is_word_start and is_word_end: + self.search_positions.append(found_pos) + pos = found_pos + 1 # Move forward to avoid infinite loop + else: + self.search_positions.append(found_pos) + pos = found_pos + len(search_text) + + if self.search_positions: + self.current_search_index = 0 + self.highlight_search_result() + self.main_frame.SetStatusText(f"Found {len(self.search_positions)} occurrences of '{search_text}'") + else: + self.main_frame.SetStatusText(f"Text '{search_text}' not found") + wx.Bell() + + except Exception as e: + logger.error(f"Error performing search: {e}") + traceback.print_exc() + + def highlight_search_result(self): + """Highlight the current search result""" + try: + if not self.search_positions or self.current_search_index < 0: + return + + pos = self.search_positions[self.current_search_index] + + # Select the found text + self.text_ctrl.SetSelection(pos, pos + len(self.search_text)) + self.text_ctrl.ShowPosition(pos) + + # Update status + self.main_frame.SetStatusText( + f"Found {self.current_search_index + 1} of {len(self.search_positions)}: '{self.search_text}'" + ) + except Exception as e: + logger.error(f"Error highlighting search result: {e}") + + def find_next(self): + """Find next occurrence""" + if self.search_positions: + self.current_search_index = (self.current_search_index + 1) % len(self.search_positions) + self.highlight_search_result() + + def find_previous(self): + """Find previous occurrence""" + if self.search_positions: + self.current_search_index = (self.current_search_index - 1) % len(self.search_positions) + self.highlight_search_result() + + def on_key_down(self, event): + try: + keycode = event.GetKeyCode() + if keycode == wx.WXK_UP: + if self.history and self.history_pos < len(self.history) - 1: + self.history_pos += 1 + self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) + elif keycode == wx.WXK_DOWN: + if self.history_pos > 0: + self.history_pos -= 1 + self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) + elif self.history_pos == 0: + self.history_pos = -1 + self.input_ctrl.Clear() + elif keycode == wx.WXK_TAB: + # Tab completion for nicknames + self.handle_tab_completion() + return # Don't skip to prevent default tab behavior + elif keycode == wx.WXK_F3: + # F3 for find next + if self.search_positions: + self.find_next() + return + elif event.ShiftDown() and keycode == wx.WXK_F3: + # Shift+F3 for find previous + if self.search_positions: + self.find_previous() + return + else: + event.Skip() + except Exception as e: + logger.error(f"Error in key handler: {e}") + event.Skip() + + def handle_tab_completion(self): + """Handle tab completion for nicknames""" + try: + current_text = self.input_ctrl.GetValue() + if not current_text or not self.target or not self.target.startswith('#'): + return + + users = self.main_frame.channel_users.get(self.target, []) + if not users: + return + + # Find word at cursor position + pos = self.input_ctrl.GetInsertionPoint() + text_before = current_text[:pos] + words = text_before.split() + + if not words: + return + + current_word = words[-1] + + # Find matching nicks + matches = [user for user in users if user.lower().startswith(current_word.lower())] + + if matches: + if len(matches) == 1: + # Single match - complete it + new_word = matches[0] + if ':' in text_before or text_before.strip().endswith(current_word): + # Replace the current word + new_text = text_before[:-len(current_word)] + new_word + current_text[pos:] + self.input_ctrl.SetValue(new_text) + self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word)) + else: + # Multiple matches - show in status + self.main_frame.SetStatusText(f"Tab completion: {', '.join(matches[:5])}{'...' if len(matches) > 5 else ''}") + except Exception as e: + logger.error(f"Error in tab completion: {e}") + + def on_send(self, event): + try: + message = self.input_ctrl.GetValue().strip() + if message and self.target: + self.history.append(message) + self.history_pos = -1 + self.main_frame.send_message(self.target, message) + self.input_ctrl.Clear() + except Exception as e: + logger.error(f"Error sending message: {e}") diff --git a/src/NotesDialog.py b/src/NotesDialog.py new file mode 100644 index 0000000..c867736 --- /dev/null +++ b/src/NotesDialog.py @@ -0,0 +1,811 @@ +import wx +import wx.richtext as rt +import json +from collections import defaultdict +import tempfile +import os +import time +import threading +from typing import Dict, Any, Optional + +class NotesDialog(wx.Frame): + def __init__(self, parent, notes_data=None, pos=None): + # If no position specified, offset from parent + if pos is None and parent: + parent_pos = parent.GetPosition() + pos = (parent_pos.x + 50, parent_pos.y + 50) + + super().__init__(parent, title="IRC Notes", size=(900, 650), pos=pos, + style=wx.DEFAULT_FRAME_STYLE) + + self.parent = parent + self.notes_data = notes_data or defaultdict(dict) + self.current_note_key = None + self.updating_title = False + self.is_closing = False + self.content_changed = False + self.last_save_time = time.time() + self.auto_save_interval = 2 # seconds - reduced for immediate saving + + self.SetBackgroundColour(wx.Colour(245, 245, 245)) + + # Set icon if parent has one + if parent: + self.SetIcon(parent.GetIcon()) + + self.create_controls() + self.load_notes_list() + + # Bind close event to save before closing + self.Bind(wx.EVT_CLOSE, self.on_close) + + # Auto-save timer (save every 2 seconds) + self.save_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.on_auto_save, self.save_timer) + self.save_timer.Start(self.auto_save_interval * 1000) # Convert to milliseconds + + # Status update timer (update status more frequently) + self.status_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self.on_status_update, self.status_timer) + self.status_timer.Start(3000) # 3 seconds + + # Initialize status + self.update_status("Ready") + + def create_controls(self): + # Create menu bar + self.create_menu_bar() + + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Main content area with splitter + splitter = wx.SplitterWindow(self, style=wx.SP_3D | wx.SP_LIVE_UPDATE) + + # Left panel - notes list + left_panel = wx.Panel(splitter) + left_sizer = wx.BoxSizer(wx.VERTICAL) + + notes_label = wx.StaticText(left_panel, label="Your Notes:") + font = notes_label.GetFont() + font.PointSize += 1 + font = font.Bold() + notes_label.SetFont(font) + left_sizer.Add(notes_label, 0, wx.ALL, 8) + + self.notes_list = wx.ListBox(left_panel, style=wx.LB_SINGLE) + self.notes_list.Bind(wx.EVT_LISTBOX, self.on_note_select) + self.notes_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_note_double_click) + left_sizer.Add(self.notes_list, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + + # Note management buttons + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.new_btn = wx.Button(left_panel, label="New Note") + self.new_btn.Bind(wx.EVT_BUTTON, self.on_new_note) + btn_sizer.Add(self.new_btn, 1, wx.ALL, 3) + + self.delete_btn = wx.Button(left_panel, label="Delete") + self.delete_btn.Bind(wx.EVT_BUTTON, self.on_delete_note) + btn_sizer.Add(self.delete_btn, 1, wx.ALL, 3) + + left_sizer.Add(btn_sizer, 0, wx.EXPAND | wx.ALL, 8) + left_panel.SetSizer(left_sizer) + + # Right panel - editor + right_panel = wx.Panel(splitter) + right_sizer = wx.BoxSizer(wx.VERTICAL) + + title_label = wx.StaticText(right_panel, label="Note Title:") + title_label.SetFont(font) + right_sizer.Add(title_label, 0, wx.ALL, 8) + + self.title_ctrl = wx.TextCtrl(right_panel, style=wx.TE_PROCESS_ENTER) + self.title_ctrl.Bind(wx.EVT_TEXT, self.on_title_change) + self.title_ctrl.Bind(wx.EVT_KILL_FOCUS, self.on_title_lose_focus) + title_font = self.title_ctrl.GetFont() + title_font.PointSize += 2 + self.title_ctrl.SetFont(title_font) + right_sizer.Add(self.title_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + + content_label = wx.StaticText(right_panel, label="Content:") + content_label.SetFont(font) + right_sizer.Add(content_label, 0, wx.LEFT | wx.RIGHT, 8) + + # Formatting toolbar + self.setup_editor_toolbar(right_panel) + right_sizer.Add(self.toolbar, 0, wx.EXPAND | wx.ALL, 8) + + # Rich text editor - FIXED: Make sure editor attribute is created + self.editor = rt.RichTextCtrl(right_panel, + style=wx.VSCROLL | wx.HSCROLL | wx.BORDER_SIMPLE) + self.editor.Bind(wx.EVT_TEXT, self.on_content_change) + self.editor.Bind(wx.EVT_KILL_FOCUS, self.on_editor_lose_focus) + + # Set default font + attr = rt.RichTextAttr() + attr.SetFontSize(11) + attr.SetFontFaceName("Segoe UI") + self.editor.SetBasicStyle(attr) + + right_sizer.Add(self.editor, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + right_panel.SetSizer(right_sizer) + + # Split the window + splitter.SplitVertically(left_panel, right_panel, 250) + splitter.SetMinimumPaneSize(200) + + main_sizer.Add(splitter, 1, wx.EXPAND | wx.ALL, 5) + + # Status bar + self.status_bar = self.CreateStatusBar(2) + self.status_bar.SetStatusWidths([-1, 150]) + self.update_status("Ready") + + self.SetSizer(main_sizer) + self.enable_editor(False) + + def create_menu_bar(self): + menubar = wx.MenuBar() + + # File menu + file_menu = wx.Menu() + export_item = file_menu.Append(wx.ID_ANY, "&Export Notes...\tCtrl+E", "Export all notes to file") + import_item = file_menu.Append(wx.ID_ANY, "&Import Notes...\tCtrl+I", "Import notes from file") + file_menu.AppendSeparator() + export_text_item = file_menu.Append(wx.ID_ANY, "Export Current Note as &Text...\tCtrl+T", "Export current note as plain text") + file_menu.AppendSeparator() + exit_item = file_menu.Append(wx.ID_EXIT, "&Close", "Close notes window") + + menubar.Append(file_menu, "&File") + + # Notes menu + notes_menu = wx.Menu() + new_note_item = notes_menu.Append(wx.ID_NEW, "&New Note\tCtrl+N", "Create a new note") + delete_note_item = notes_menu.Append(wx.ID_DELETE, "&Delete Note\tDel", "Delete current note") + notes_menu.AppendSeparator() + rename_note_item = notes_menu.Append(wx.ID_ANY, "&Rename Note\tF2", "Rename current note") + + menubar.Append(notes_menu, "&Notes") + + # Edit menu + edit_menu = wx.Menu() + undo_item = edit_menu.Append(wx.ID_UNDO, "&Undo\tCtrl+Z", "Undo last action") + redo_item = edit_menu.Append(wx.ID_REDO, "&Redo\tCtrl+Y", "Redo last action") + edit_menu.AppendSeparator() + cut_item = edit_menu.Append(wx.ID_CUT, "Cu&t\tCtrl+X", "Cut selection") + copy_item = edit_menu.Append(wx.ID_COPY, "&Copy\tCtrl+C", "Copy selection") + paste_item = edit_menu.Append(wx.ID_PASTE, "&Paste\tCtrl+V", "Paste from clipboard") + edit_menu.AppendSeparator() + select_all_item = edit_menu.Append(wx.ID_SELECTALL, "Select &All\tCtrl+A", "Select all text") + + menubar.Append(edit_menu, "&Edit") + + # Format menu + format_menu = wx.Menu() + bold_item = format_menu.Append(wx.ID_ANY, "&Bold\tCtrl+B", "Bold text") + italic_item = format_menu.Append(wx.ID_ANY, "&Italic\tCtrl+I", "Italic text") + underline_item = format_menu.Append(wx.ID_ANY, "&Underline\tCtrl+U", "Underline text") + format_menu.AppendSeparator() + font_color_item = format_menu.Append(wx.ID_ANY, "&Text Color...", "Change text color") + bg_color_item = format_menu.Append(wx.ID_ANY, "&Highlight Color...", "Change highlight color") + format_menu.AppendSeparator() + clear_format_item = format_menu.Append(wx.ID_ANY, "&Clear Formatting\tCtrl+Space", "Clear text formatting") + + menubar.Append(format_menu, "F&ormat") + + self.SetMenuBar(menubar) + + # Bind menu events + self.Bind(wx.EVT_MENU, self.on_save_to_file, export_item) + self.Bind(wx.EVT_MENU, self.on_load_from_file, import_item) + self.Bind(wx.EVT_MENU, self.on_export_text, export_text_item) + self.Bind(wx.EVT_MENU, self.on_close, exit_item) + self.Bind(wx.EVT_MENU, self.on_new_note, new_note_item) + self.Bind(wx.EVT_MENU, self.on_delete_note, delete_note_item) + self.Bind(wx.EVT_MENU, self.on_rename_note, rename_note_item) + self.Bind(wx.EVT_MENU, self.on_bold, bold_item) + self.Bind(wx.EVT_MENU, self.on_italic, italic_item) + self.Bind(wx.EVT_MENU, self.on_underline, underline_item) + self.Bind(wx.EVT_MENU, self.on_text_color, font_color_item) + self.Bind(wx.EVT_MENU, self.on_background_color, bg_color_item) + self.Bind(wx.EVT_MENU, self.on_clear_formatting, clear_format_item) + self.Bind(wx.EVT_MENU, self.on_undo, undo_item) + self.Bind(wx.EVT_MENU, self.on_redo, redo_item) + self.Bind(wx.EVT_MENU, self.on_cut, cut_item) + self.Bind(wx.EVT_MENU, self.on_copy, copy_item) + self.Bind(wx.EVT_MENU, self.on_paste, paste_item) + self.Bind(wx.EVT_MENU, self.on_select_all, select_all_item) + + def setup_editor_toolbar(self, parent): + self.toolbar = wx.Panel(parent) + toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # Text formatting buttons + self.bold_btn = wx.Button(self.toolbar, label="B", size=(35, 30)) + self.bold_btn.SetFont(self.bold_btn.GetFont().Bold()) + self.bold_btn.Bind(wx.EVT_BUTTON, self.on_bold) + self.bold_btn.SetToolTip("Bold (Ctrl+B)") + toolbar_sizer.Add(self.bold_btn, 0, wx.ALL, 2) + + self.italic_btn = wx.Button(self.toolbar, label="I", size=(35, 30)) + font = self.italic_btn.GetFont() + font.MakeItalic() + self.italic_btn.SetFont(font) + self.italic_btn.Bind(wx.EVT_BUTTON, self.on_italic) + self.italic_btn.SetToolTip("Italic (Ctrl+I)") + toolbar_sizer.Add(self.italic_btn, 0, wx.ALL, 2) + + self.underline_btn = wx.Button(self.toolbar, label="U", size=(35, 30)) + self.underline_btn.Bind(wx.EVT_BUTTON, self.on_underline) + self.underline_btn.SetToolTip("Underline (Ctrl+U)") + toolbar_sizer.Add(self.underline_btn, 0, wx.ALL, 2) + + toolbar_sizer.Add(wx.StaticLine(self.toolbar, style=wx.LI_VERTICAL), 0, + wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + + # Font size + toolbar_sizer.Add(wx.StaticText(self.toolbar, label="Size:"), 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 2) + self.font_size_choice = wx.Choice(self.toolbar, choices=['8', '9', '10', '11', '12', '14', '16', '18', '20', '24']) + self.font_size_choice.SetSelection(3) # Default to 11 + self.font_size_choice.Bind(wx.EVT_CHOICE, self.on_font_size) + self.font_size_choice.SetToolTip("Font Size") + toolbar_sizer.Add(self.font_size_choice, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 2) + + toolbar_sizer.Add(wx.StaticLine(self.toolbar, style=wx.LI_VERTICAL), 0, + wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + + # Alignment buttons + self.align_left_btn = wx.Button(self.toolbar, label="Left", size=(45, 30)) + self.align_left_btn.Bind(wx.EVT_BUTTON, self.on_align_left) + self.align_left_btn.SetToolTip("Align Left") + toolbar_sizer.Add(self.align_left_btn, 0, wx.ALL, 2) + + self.align_center_btn = wx.Button(self.toolbar, label="Center", size=(55, 30)) + self.align_center_btn.Bind(wx.EVT_BUTTON, self.on_align_center) + self.align_center_btn.SetToolTip("Align Center") + toolbar_sizer.Add(self.align_center_btn, 0, wx.ALL, 2) + + self.align_right_btn = wx.Button(self.toolbar, label="Right", size=(45, 30)) + self.align_right_btn.Bind(wx.EVT_BUTTON, self.on_align_right) + self.align_right_btn.SetToolTip("Align Right") + toolbar_sizer.Add(self.align_right_btn, 0, wx.ALL, 2) + + toolbar_sizer.Add(wx.StaticLine(self.toolbar, style=wx.LI_VERTICAL), 0, + wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + + # Color buttons + self.text_color_btn = wx.Button(self.toolbar, label="Text Color", size=(80, 30)) + self.text_color_btn.Bind(wx.EVT_BUTTON, self.on_text_color) + self.text_color_btn.SetToolTip("Text Color") + toolbar_sizer.Add(self.text_color_btn, 0, wx.ALL, 2) + + self.bg_color_btn = wx.Button(self.toolbar, label="Highlight", size=(80, 30)) + self.bg_color_btn.Bind(wx.EVT_BUTTON, self.on_background_color) + self.bg_color_btn.SetToolTip("Highlight Color") + toolbar_sizer.Add(self.bg_color_btn, 0, wx.ALL, 2) + + toolbar_sizer.AddStretchSpacer() + + # Clear formatting + self.clear_fmt_btn = wx.Button(self.toolbar, label="Clear Format", size=(100, 30)) + self.clear_fmt_btn.Bind(wx.EVT_BUTTON, self.on_clear_formatting) + self.clear_fmt_btn.SetToolTip("Remove all formatting (Ctrl+Space)") + toolbar_sizer.Add(self.clear_fmt_btn, 0, wx.ALL, 2) + + self.toolbar.SetSizer(toolbar_sizer) + self.toolbar.SetBackgroundColour(wx.Colour(230, 230, 230)) + + def enable_editor(self, enable): + self.title_ctrl.Enable(enable) + self.editor.Enable(enable) + self.delete_btn.Enable(enable) + + # Enable/disable toolbar buttons + for child in self.toolbar.GetChildren(): + if isinstance(child, wx.Button) or isinstance(child, wx.Choice): + child.Enable(enable) + + def update_status(self, message: str): + """Update status text with timestamp""" + timestamp = wx.DateTime.Now().FormatTime() + self.status_bar.SetStatusText(f"{timestamp} - {message}", 0) + + # Update save status in second field + if self.current_note_key and self.content_changed: + time_since_change = int(time.time() - self.notes_data[self.current_note_key]['last_modified']) + self.status_bar.SetStatusText(f"Unsaved changes ({time_since_change}s)", 1) + elif self.current_note_key: + time_since_save = int(time.time() - self.last_save_time) + self.status_bar.SetStatusText(f"Saved {time_since_save}s ago", 1) + else: + self.status_bar.SetStatusText("", 1) + + def load_notes_list(self): + self.notes_list.Clear() + sorted_keys = sorted(self.notes_data.keys(), + key=lambda k: self.notes_data[k].get('last_modified', 0), + reverse=True) + for key in sorted_keys: + display_title = self.notes_data[key].get('title', key) + self.notes_list.Append(display_title, key) + + def save_current_note_formatting(self) -> bool: + """Save the current note's formatting to XML""" + if not self.current_note_key or self.is_closing: + return False + + try: + # Save plain text + self.notes_data[self.current_note_key]['content'] = self.editor.GetValue() + + # Save XML with formatting using a temporary file + with tempfile.NamedTemporaryFile(mode='w+', suffix='.xml', delete=False, encoding='utf-8') as tmp: + tmp_path = tmp.name + + # Save to temp file + handler = rt.RichTextXMLHandler() + buffer = self.editor.GetBuffer() + + if buffer and handler.SaveFile(buffer, tmp_path): + # Read back the XML content + with open(tmp_path, 'r', encoding='utf-8') as f: + xml_content = f.read() + self.notes_data[self.current_note_key]['xml_content'] = xml_content + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + # Clean up temp file + try: + os.unlink(tmp_path) + except Exception: + pass + + self.content_changed = False + self.last_save_time = time.time() + return True + + except Exception as e: + print(f"Error saving note formatting: {e}") + return False + + def auto_save_current_note(self): + """Auto-save current note if changes were made""" + if self.current_note_key and self.content_changed: + if self.save_current_note_formatting(): + return True + return False + + def safe_load_xml_content(self, xml_content: str) -> bool: + """Safely load XML content with error handling""" + try: + # Create a temporary file for XML loading + with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False, encoding='utf-8') as tmp: + tmp.write(xml_content) + tmp_path = tmp.name + + # Clear current buffer first + self.editor.Clear() + + # Load from temp file + handler = rt.RichTextXMLHandler() + success = handler.LoadFile(self.editor.GetBuffer(), tmp_path) + + # Clean up temp file + try: + os.unlink(tmp_path) + except Exception: + pass + + if success: + self.editor.Refresh() + return True + else: + # If XML load fails, fall back to plain text + return False + + except Exception as e: + print(f"Error loading XML content: {e}") + return False + + def on_note_select(self, event): + # Auto-save previous note before switching + if self.current_note_key: + self.auto_save_current_note() + + selection = self.notes_list.GetSelection() + if selection != wx.NOT_FOUND: + key = self.notes_list.GetClientData(selection) + self.current_note_key = key + note_data = self.notes_data[key] + + self.updating_title = True + self.title_ctrl.SetValue(note_data.get('title', '')) + self.updating_title = False + + # Load content - check if it's XML format or plain text + content = note_data.get('content', '') + xml_content = note_data.get('xml_content', '') + + # Clear any existing content first + self.editor.Clear() + + if xml_content: + # Try to load rich text from XML + if not self.safe_load_xml_content(xml_content): + # Fallback to plain text if XML load fails + self.editor.SetValue(content) + elif content: + # Fallback to plain text + self.editor.SetValue(content) + else: + self.editor.Clear() + + self.enable_editor(True) + self.editor.SetFocus() + self.content_changed = False + self.update_status(f"Loaded note: {note_data.get('title', 'Untitled')}") + + def on_note_double_click(self, event): + """Rename note on double click""" + self.on_rename_note(event) + + def on_new_note(self, event): + note_id = f"note_{int(time.time() * 1000)}" # More precise timestamp + title = f"New Note {len(self.notes_data) + 1}" + self.notes_data[note_id] = { + 'title': title, + 'content': '', + 'xml_content': '', + 'created': time.time(), + 'last_modified': time.time() + } + self.load_notes_list() + + # Select and edit the new note + for i in range(self.notes_list.GetCount()): + if self.notes_list.GetClientData(i) == note_id: + self.notes_list.SetSelection(i) + self.on_note_select(event) + self.title_ctrl.SetFocus() + self.title_ctrl.SelectAll() + break + + def on_delete_note(self, event): + if self.current_note_key: + note_title = self.notes_data[self.current_note_key].get('title', 'this note') + result = wx.MessageBox( + f"Are you sure you want to delete '{note_title}'?", + "Confirm Delete", + wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION + ) + if result == wx.YES: + del self.notes_data[self.current_note_key] + self.current_note_key = None + self.load_notes_list() + self.title_ctrl.SetValue('') + self.editor.Clear() + self.enable_editor(False) + self.update_status("Note deleted") + + def on_rename_note(self, event): + """Rename current note by focusing on title field""" + if self.current_note_key: + self.title_ctrl.SetFocus() + self.title_ctrl.SelectAll() + + def on_title_change(self, event): + if self.current_note_key and not self.updating_title: + new_title = self.title_ctrl.GetValue().strip() + if new_title: + self.notes_data[self.current_note_key]['title'] = new_title + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + # Update the display in the list + for i in range(self.notes_list.GetCount()): + if self.notes_list.GetClientData(i) == self.current_note_key: + self.notes_list.SetString(i, new_title) + break + + self.content_changed = True + # Auto-save immediately on title change + self.auto_save_current_note() + self.update_status("Title updated") + + def on_title_lose_focus(self, event): + """Auto-save when title loses focus""" + if self.content_changed: + self.auto_save_current_note() + event.Skip() + + def on_content_change(self, event): + """Mark content as changed for auto-save""" + if self.current_note_key: + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + # Trigger immediate auto-save on content change + wx.CallLater(1000, self.auto_save_current_note) # Save after 1 second delay + event.Skip() + + def on_editor_lose_focus(self, event): + """Auto-save when editor loses focus""" + if self.content_changed: + self.auto_save_current_note() + event.Skip() + + def on_auto_save(self, event): + """Auto-save current note on timer""" + if self.auto_save_current_note(): + self.update_status(f"Auto-saved at {wx.DateTime.Now().FormatTime()}") + + def on_status_update(self, event): + """Update status bar""" + self.update_status("Ready") + + # Text formatting methods + def on_bold(self, event): + self.editor.ApplyBoldToSelection() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_italic(self, event): + self.editor.ApplyItalicToSelection() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_underline(self, event): + self.editor.ApplyUnderlineToSelection() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_font_size(self, event): + size = int(self.font_size_choice.GetStringSelection()) + attr = rt.RichTextAttr() + attr.SetFontSize(size) + + range_obj = self.editor.GetSelectionRange() + if range_obj.GetLength() > 0: + self.editor.SetStyle(range_obj, attr) + else: + # Apply to next typed text + self.editor.SetDefaultStyle(attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_align_left(self, event): + attr = rt.RichTextAttr() + attr.SetAlignment(wx.TEXT_ALIGNMENT_LEFT) + + # Get current paragraph range + pos = self.editor.GetInsertionPoint() + para = self.editor.GetBuffer().GetParagraphAtPosition(pos) + if para: + range_obj = para.GetRange() + self.editor.SetStyle(range_obj, attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_align_center(self, event): + attr = rt.RichTextAttr() + attr.SetAlignment(wx.TEXT_ALIGNMENT_CENTRE) + + pos = self.editor.GetInsertionPoint() + para = self.editor.GetBuffer().GetParagraphAtPosition(pos) + if para: + range_obj = para.GetRange() + self.editor.SetStyle(range_obj, attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_align_right(self, event): + attr = rt.RichTextAttr() + attr.SetAlignment(wx.TEXT_ALIGNMENT_RIGHT) + + pos = self.editor.GetInsertionPoint() + para = self.editor.GetBuffer().GetParagraphAtPosition(pos) + if para: + range_obj = para.GetRange() + self.editor.SetStyle(range_obj, attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_text_color(self, event): + color_data = wx.ColourData() + color_data.SetChooseFull(True) + + dlg = wx.ColourDialog(self, color_data) + if dlg.ShowModal() == wx.ID_OK: + color = dlg.GetColourData().GetColour() + attr = rt.RichTextAttr() + attr.SetTextColour(color) + + range_obj = self.editor.GetSelectionRange() + if range_obj.GetLength() > 0: + self.editor.SetStyle(range_obj, attr) + else: + self.editor.SetDefaultStyle(attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + dlg.Destroy() + + def on_background_color(self, event): + color_data = wx.ColourData() + color_data.SetChooseFull(True) + + dlg = wx.ColourDialog(self, color_data) + if dlg.ShowModal() == wx.ID_OK: + color = dlg.GetColourData().GetColour() + attr = rt.RichTextAttr() + attr.SetBackgroundColour(color) + + range_obj = self.editor.GetSelectionRange() + if range_obj.GetLength() > 0: + self.editor.SetStyle(range_obj, attr) + else: + self.editor.SetDefaultStyle(attr) + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + dlg.Destroy() + + def on_clear_formatting(self, event): + range_obj = self.editor.GetSelectionRange() + if range_obj.GetLength() > 0: + # Get the text + text = self.editor.GetRange(range_obj.GetStart(), range_obj.GetEnd()) + + # Delete the range and reinsert as plain text + self.editor.Delete(range_obj) + + # Set basic style + attr = rt.RichTextAttr() + attr.SetFontSize(11) + attr.SetFontFaceName("Segoe UI") + attr.SetTextColour(wx.BLACK) + attr.SetBackgroundColour(wx.NullColour) + + self.editor.BeginStyle(attr) + self.editor.WriteText(text) + self.editor.EndStyle() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + # Menu command handlers + def on_undo(self, event): + if self.editor.CanUndo(): + self.editor.Undo() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_redo(self, event): + if self.editor.CanRedo(): + self.editor.Redo() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_cut(self, event): + self.editor.Cut() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_copy(self, event): + self.editor.Copy() + + def on_paste(self, event): + self.editor.Paste() + self.content_changed = True + self.notes_data[self.current_note_key]['last_modified'] = time.time() + + def on_select_all(self, event): + self.editor.SelectAll() + + def on_export_text(self, event): + if not self.current_note_key: + return + + note_data = self.notes_data[self.current_note_key] + title = note_data.get('title', 'note') + + with wx.FileDialog(self, "Export note as text", + defaultFile=f"{title}.txt", + wildcard="Text files (*.txt)|*.txt", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as dlg: + if dlg.ShowModal() == wx.ID_OK: + filename = dlg.GetPath() + try: + with open(filename, 'w', encoding='utf-8') as f: + f.write(f"{title}\n") + f.write("=" * len(title) + "\n\n") + f.write(self.editor.GetValue()) + wx.MessageBox("Note exported successfully!", "Success", + wx.OK | wx.ICON_INFORMATION) + self.update_status("Note exported to text file") + except Exception as e: + wx.MessageBox(f"Error exporting note: {e}", "Error", + wx.OK | wx.ICON_ERROR) + + def on_save_to_file(self, event): + # Save current note first + if self.current_note_key: + self.auto_save_current_note() + + with wx.FileDialog(self, "Export notes to file", + wildcard="JSON files (*.json)|*.json", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as dlg: + if dlg.ShowModal() == wx.ID_OK: + filename = dlg.GetPath() + try: + # Convert defaultdict to regular dict for JSON serialization + notes_dict = {k: dict(v) for k, v in self.notes_data.items()} + with open(filename, 'w', encoding='utf-8') as f: + json.dump(notes_dict, f, indent=2, ensure_ascii=False) + wx.MessageBox("Notes exported successfully!", "Success", + wx.OK | wx.ICON_INFORMATION) + self.update_status("All notes exported to file") + except Exception as e: + wx.MessageBox(f"Error saving file: {e}", "Error", + wx.OK | wx.ICON_ERROR) + + def on_load_from_file(self, event): + with wx.FileDialog(self, "Import notes from file", + wildcard="JSON files (*.json)|*.json", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg: + if dlg.ShowModal() == wx.ID_OK: + filename = dlg.GetPath() + try: + with open(filename, 'r', encoding='utf-8') as f: + loaded_data = json.load(f) + + # Auto-save current note before importing + if self.current_note_key: + self.auto_save_current_note() + + # Merge with existing notes + result = wx.MessageBox( + "Replace existing notes or merge with them?\n\nYes = Replace, No = Merge", + "Import Options", + wx.YES_NO | wx.CANCEL | wx.ICON_QUESTION + ) + + if result == wx.YES: # Replace + self.notes_data.clear() + self.notes_data.update(loaded_data) + self.update_status("All notes replaced with imported file") + elif result == wx.NO: # Merge + self.notes_data.update(loaded_data) + self.update_status("Imported notes merged with existing notes") + else: # Cancel + return + + self.load_notes_list() + self.current_note_key = None + self.title_ctrl.SetValue('') + self.editor.Clear() + self.enable_editor(False) + wx.MessageBox("Notes imported successfully!", "Success", + wx.OK | wx.ICON_INFORMATION) + except Exception as e: + wx.MessageBox(f"Error loading file: {e}", "Error", + wx.OK | wx.ICON_ERROR) + + def on_close(self, event): + """Save everything before closing""" + self.is_closing = True + + # Stop timers + self.save_timer.Stop() + self.status_timer.Stop() + + # Save current note + if self.current_note_key: + self.auto_save_current_note() + self.update_status("Final auto-save completed") + + # Notify parent if it has a callback + if self.parent and hasattr(self.parent, 'on_notes_closed'): + self.parent.on_notes_closed() + + self.Destroy() + + def get_notes_data(self): + """Get all notes data""" + # Save current note before returning + if self.current_note_key: + self.auto_save_current_note() + return self.notes_data \ No newline at end of file diff --git a/src/PrivacyNoticeDialog.py b/src/PrivacyNoticeDialog.py new file mode 100644 index 0000000..dc74b7c --- /dev/null +++ b/src/PrivacyNoticeDialog.py @@ -0,0 +1,396 @@ +import wx +import platform +import psutil +import socket +import getpass +import subprocess +import re +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class PrivacyNoticeDialog(wx.Dialog): + def __init__(self, parent): + super().__init__(parent, title="Privacy Notice", style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + self.parent = parent + + # Calculate optimal dialog size based on screen size + screen_width, screen_height = wx.DisplaySize() + self.max_width = min(700, screen_width * 0.75) + self.max_height = min(700, screen_height * 0.8) + + self.SetMinSize((450, 400)) + self.SetSize((self.max_width, self.max_height)) + + # Create main sizer + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Create header with icon and title + header_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # Add info icon + info_icon = wx.ArtProvider.GetBitmap(wx.ART_INFORMATION, wx.ART_MESSAGE_BOX, (32, 32)) + icon_ctrl = wx.StaticBitmap(self, -1, info_icon) + header_sizer.Add(icon_ctrl, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10) + + # Add title + title_text = wx.StaticText(self, label="Privacy and System Information") + title_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) + title_text.SetFont(title_font) + header_sizer.Add(title_text, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10) + + main_sizer.Add(header_sizer, 0, wx.EXPAND) + + # Create scrolled window for the content + self.scrolled_win = wx.ScrolledWindow(self) + self.scrolled_win.SetScrollRate(10, 10) + + scrolled_sizer = wx.BoxSizer(wx.VERTICAL) + + # Get system information + system_info = self.get_system_info() + + # Security and privacy notice section (moved to top) + security_text = wx.StaticText(self.scrolled_win, label="Security and Privacy Notice:") + security_font = wx.Font(11, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) + security_text.SetFont(security_font) + scrolled_sizer.Add(security_text, 0, wx.ALL, 5) + + # Detect potential security features + security_warnings = self.get_security_warnings(system_info) + + privacy_notice = ( + "wxIRC is an Open Source project and must not be distributed for commercial purposes.\n\n" + + "Security Considerations:\n" + f"{security_warnings}\n\n" + + "Important Reminders:\n" + "Your system contains hardware level security processors that operate independently\n" + " Network traffic may be monitored by various entities\n" + " Never discuss or plan illegal activities through any communication platform\n" + " Keep your system and software updated for security patches\n" + " Use strong, unique passwords and enable 2FA where available\n\n" + + "This application collects no personal data and makes no network connections\n" + "beyond those required for IRC functionality that you explicitly initiate." + ) + + self.body_text = wx.StaticText(self.scrolled_win, label=privacy_notice) + scrolled_sizer.Add(self.body_text, 0, wx.ALL | wx.EXPAND, 10) + + # Add separator + scrolled_sizer.Add(wx.StaticLine(self.scrolled_win), 0, wx.EXPAND | wx.ALL, 10) + + # System information section (moved to bottom) + sysinfo_text = wx.StaticText(self.scrolled_win, label="System Information:") + sysinfo_text.SetFont(security_font) + scrolled_sizer.Add(sysinfo_text, 0, wx.ALL, 5) + + # System info details + info_details = ( + f"Operating System: {system_info['os']}\n" + f"Architecture: {system_info['architecture']}\n" + f"Processor: {system_info['processor']}\n" + f"Physical Cores: {system_info['physical_cores']}\n" + f"Total Cores: {system_info['total_cores']}\n" + f"Max Frequency: {system_info['max_frequency']} MHz\n" + f"Total RAM: {system_info['total_ram']} GB\n" + f"Available RAM: {system_info['available_ram']} GB\n" + f"Hostname: {system_info['hostname']}\n" + f"Username: {system_info['username']}\n" + f"Python Version: {system_info['python_version']}\n" + f"wxPython Version: {system_info['wx_version']}" + ) + + info_text = wx.StaticText(self.scrolled_win, label=info_details) + scrolled_sizer.Add(info_text, 0, wx.ALL | wx.EXPAND, 10) + + self.scrolled_win.SetSizer(scrolled_sizer) + main_sizer.Add(self.scrolled_win, 1, wx.EXPAND | wx.ALL, 5) + + # Add OK button + ok_btn = wx.Button(self, wx.ID_OK, "I Understand and Continue") + ok_btn.SetDefault() + ok_btn.SetMinSize((160, 35)) + + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + btn_sizer.AddStretchSpacer() + btn_sizer.Add(ok_btn, 0, wx.ALL, 10) + btn_sizer.AddStretchSpacer() + + main_sizer.Add(btn_sizer, 0, wx.EXPAND) + + self.SetSizer(main_sizer) + + # Center on parent + self.CentreOnParent() + + # Bind events + self.Bind(wx.EVT_BUTTON, self.on_ok, ok_btn) + self.Bind(wx.EVT_SIZE, self.on_resize) + + # Fit the dialog to content + self.Fit() + self.adjust_to_screen() + + def get_system_info(self): + """Gather comprehensive system information with improved processor detection""" + try: + # CPU information + cpu_freq = psutil.cpu_freq() + max_freq = int(cpu_freq.max) if cpu_freq else "N/A" + + # Memory information + memory = psutil.virtual_memory() + total_ram_gb = round(memory.total / (1024**3), 1) + available_ram_gb = round(memory.available / (1024**3), 1) + + # Platform information + system = platform.system() + if system == "Windows": + os_info = f"Windows {platform.release()}" + elif system == "Linux": + # Try to get distro info for Linux + try: + with open('/etc/os-release', 'r') as f: + lines = f.readlines() + for line in lines: + if line.startswith('PRETTY_NAME='): + os_info = line.split('=', 1)[1].strip().strip('"') + break + else: + os_info = f"Linux ({platform.release()})" + except: + os_info = f"Linux ({platform.release()})" + elif system == "Darwin": + os_info = f"macOS {platform.mac_ver()[0]}" + else: + os_info = f"{system} {platform.release()}" + + # Improved processor detection + processor = self.detect_processor() + + return { + 'os': os_info, + 'architecture': platform.architecture()[0], + 'processor': processor, + 'physical_cores': psutil.cpu_count(logical=False), + 'total_cores': psutil.cpu_count(logical=True), + 'max_frequency': max_freq, + 'total_ram': total_ram_gb, + 'available_ram': available_ram_gb, + 'hostname': socket.gethostname(), + 'username': getpass.getuser(), + 'python_version': platform.python_version(), + 'wx_version': wx.VERSION_STRING, + 'platform': platform.platform() + } + except Exception as e: + logger.error(f"Error gathering system info: {e}") + return { + 'os': "Unknown", + 'architecture': "Unknown", + 'processor': "Unknown", + 'physical_cores': "N/A", + 'total_cores': "N/A", + 'max_frequency': "N/A", + 'total_ram': "N/A", + 'available_ram': "N/A", + 'hostname': "Unknown", + 'username': "Unknown", + 'python_version': platform.python_version(), + 'wx_version': wx.VERSION_STRING, + 'platform': "Unknown" + } + + def detect_processor(self): + """Improved processor detection for Ryzen and other CPUs""" + try: + # Method 1: Try platform.processor() first + processor = platform.processor() + if processor and processor.strip() and processor != "unknown": + return processor.strip() + + # Method 2: Try CPU info file on Linux + if platform.system() == "Linux": + try: + with open('/proc/cpuinfo', 'r') as f: + cpuinfo = f.read() + + # Look for model name + for line in cpuinfo.split('\n'): + if line.startswith('model name') or line.startswith('Model Name') or line.startswith('Processor'): + parts = line.split(':', 1) + if len(parts) > 1: + cpu_name = parts[1].strip() + if cpu_name: + return cpu_name + + # Look for hardware field + for line in cpuinfo.split('\n'): + if line.startswith('Hardware'): + parts = line.split(':', 1) + if len(parts) > 1: + cpu_name = parts[1].strip() + if cpu_name: + return cpu_name + + except Exception as e: + logger.debug(f"Error reading /proc/cpuinfo: {e}") + + # Method 3: Try lscpu command + try: + result = subprocess.run(['lscpu'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'Model name:' in line: + parts = line.split(':', 1) + if len(parts) > 1: + cpu_name = parts[1].strip() + if cpu_name: + return cpu_name + except Exception as e: + logger.debug(f"Error running lscpu: {e}") + + # Method 4: Try sysctl on macOS + if platform.system() == "Darwin": + try: + result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + cpu_name = result.stdout.strip() + if cpu_name: + return cpu_name + except Exception as e: + logger.debug(f"Error running sysctl: {e}") + + # Method 5: Try WMI on Windows + if platform.system() == "Windows": + try: + import ctypes + from ctypes import wintypes + + # Try kernel32 GetNativeSystemInfo + kernel32 = ctypes.windll.kernel32 + + class SYSTEM_INFO(ctypes.Structure): + _fields_ = [ + ("wProcessorArchitecture", wintypes.WORD), + ("wReserved", wintypes.WORD), + ("dwPageSize", wintypes.DWORD), + ("lpMinimumApplicationAddress", ctypes.c_void_p), + ("lpMaximumApplicationAddress", ctypes.c_void_p), + ("dwActiveProcessorMask", ctypes.c_void_p), + ("dwNumberOfProcessors", wintypes.DWORD), + ("dwProcessorType", wintypes.DWORD), + ("dwAllocationGranularity", wintypes.DWORD), + ("wProcessorLevel", wintypes.WORD), + ("wProcessorRevision", wintypes.WORD) + ] + + system_info = SYSTEM_INFO() + kernel32.GetNativeSystemInfo(ctypes.byref(system_info)) + + # Map processor type to name + processor_types = { + 586: "Pentium", + 8664: "x64", + } + + if system_info.dwProcessorType in processor_types: + return processor_types[system_info.dwProcessorType] + + except Exception as e: + logger.debug(f"Error with Windows processor detection: {e}") + + # Final fallback + return f"Unknown ({platform.machine()})" + + except Exception as e: + logger.error(f"Error in processor detection: {e}") + return "Unknown" + + def get_security_warnings(self, system_info): + """Generate security warnings based on system capabilities""" + warnings = [] + + try: + # Check for Intel ME / AMD PSP indicators + processor_lower = system_info['processor'].lower() + + if 'intel' in processor_lower: + warnings.append(" Intel Processor detected: Management Engine (ME) present") + if 'core' in processor_lower and any(gen in processor_lower for gen in ['i3', 'i5', 'i7', 'i9']): + warnings.append(" Modern Intel Core processor: ME capabilities active") + + elif 'amd' in processor_lower: + warnings.append(" AMD Processor detected: Platform Security Processor (PSP) present") + if 'ryzen' in processor_lower: + warnings.append(" Modern AMD Ryzen processor: PSP capabilities active") + if '5600x' in processor_lower: + warnings.append(" AMD Ryzen 5 5600X: Zen 3 architecture with PSP") + + # Check for specific AMD models + if '5600x' in processor_lower: + warnings.append(" AMD Ryzen 5 5600X detected: Hardware level security processor active") + + # Check RAM size + if system_info['total_ram'] != "N/A" and system_info['total_ram'] > 8: + warnings.append(" System has substantial RAM capacity for background processes") + + # Check core count + if system_info['total_cores'] != "N/A" and system_info['total_cores'] >= 4: + warnings.append(" Multi core system capable of parallel background processing") + + # Network capabilities + warnings.append(" System has network connectivity capabilities") + warnings.append(" WiFi/Ethernet hardware present for data transmission") + + # Operating system specific + os_lower = system_info['os'].lower() + if 'windows' in os_lower: + warnings.append(" Windows OS: Telemetry and background services active") + elif 'linux' in os_lower or 'arch' in os_lower: + warnings.append(" Linux OS: Generally more transparent, but hardware level components still active") + if 'arch' in os_lower: + warnings.append(" Arch Linux: Rolling release, ensure regular security updates") + elif 'mac' in os_lower: + warnings.append(" macOS: Apple T2/T1 security chip or Apple Silicon present") + + except Exception as e: + logger.error(f"Error generating security warnings: {e}") + warnings = [" Unable to fully assess system security features"] + + return "\n".join(warnings) + + def adjust_to_screen(self): + """Adjust dialog size and position to fit within screen bounds""" + screen_width, screen_height = wx.DisplaySize() + current_size = self.GetSize() + + final_width = min(current_size.width, screen_width - 40) + final_height = min(current_size.height, screen_height - 40) + + self.SetSize(final_width, final_height) + self.body_text.Wrap(final_width - 60) + self.Layout() + self.scrolled_win.FitInside() + self.CentreOnParent() + + def on_resize(self, event): + """Handle dialog resize to re-wrap text appropriately""" + try: + if hasattr(self, 'body_text'): + new_width = self.GetSize().width - 60 + if new_width > 200: + self.body_text.Wrap(new_width) + self.scrolled_win.Layout() + self.scrolled_win.FitInside() + except Exception as e: + logger.error(f"Error in resize handler: {e}") + event.Skip() + + def on_ok(self, event): + self.EndModal(wx.ID_OK) diff --git a/src/SearchDialog.py b/src/SearchDialog.py new file mode 100644 index 0000000..d6f6c66 --- /dev/null +++ b/src/SearchDialog.py @@ -0,0 +1,53 @@ +import wx + +class SearchDialog(wx.Dialog): + def __init__(self, parent): + super().__init__(parent, title="Search", style=wx.DEFAULT_DIALOG_STYLE) + self.parent = parent + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Search input + search_sizer = wx.BoxSizer(wx.HORIZONTAL) + search_sizer.Add(wx.StaticText(self, label="Search:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) + self.search_ctrl = wx.TextCtrl(self, size=(200, -1), style=wx.TE_PROCESS_ENTER) + self.search_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_search) + search_sizer.Add(self.search_ctrl, 1, wx.EXPAND | wx.ALL, 5) + + sizer.Add(search_sizer, 0, wx.EXPAND) + + # Options + options_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.case_sensitive = wx.CheckBox(self, label="Case sensitive") + self.whole_word = wx.CheckBox(self, label="Whole word") + options_sizer.Add(self.case_sensitive, 0, wx.ALL, 5) + options_sizer.Add(self.whole_word, 0, wx.ALL, 5) + + sizer.Add(options_sizer, 0, wx.EXPAND) + + # Buttons + btn_sizer = wx.StdDialogButtonSizer() + self.search_btn = wx.Button(self, wx.ID_OK, "Search") + self.search_btn.SetDefault() + self.search_btn.Bind(wx.EVT_BUTTON, self.on_search) + btn_sizer.AddButton(self.search_btn) + + close_btn = wx.Button(self, wx.ID_CANCEL, "Close") + btn_sizer.AddButton(close_btn) + btn_sizer.Realize() + + sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) + + self.SetSizer(sizer) + self.Fit() + + self.search_ctrl.SetFocus() + + def on_search(self, event): + search_text = self.search_ctrl.GetValue().strip() + if search_text: + self.parent.perform_search( + search_text, + self.case_sensitive.IsChecked(), + self.whole_word.IsChecked() + ) \ No newline at end of file diff --git a/src/icon.ico b/src/icon.ico new file mode 100644 index 0000000..0cf7dee Binary files /dev/null and b/src/icon.ico differ diff --git a/main.py b/src/main.py similarity index 58% rename from main.py rename to src/main.py index 0706187..7aee8a5 100644 --- a/main.py +++ b/src/main.py @@ -10,7 +10,12 @@ import socket import logging import queue import os -import sys +import sys + +from PrivacyNoticeDialog import PrivacyNoticeDialog +from IRCPanel import IRCPanel +from AboutDialog import AboutDialog +from NotesDialog import NotesDialog # Add this import # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -26,398 +31,6 @@ def get_resource_path(relative_path): return os.path.join(base_path, relative_path) -import platform -import psutil -import socket -import getpass -import subprocess -import re - -class PrivacyNoticeDialog(wx.Dialog): - def __init__(self, parent): - super().__init__(parent, title="Privacy Notice", style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) - self.parent = parent - - # Calculate optimal dialog size based on screen size - screen_width, screen_height = wx.DisplaySize() - self.max_width = min(700, screen_width * 0.75) - self.max_height = min(700, screen_height * 0.8) - - self.SetMinSize((450, 400)) - self.SetSize((self.max_width, self.max_height)) - - # Create main sizer - main_sizer = wx.BoxSizer(wx.VERTICAL) - - # Create header with icon and title - header_sizer = wx.BoxSizer(wx.HORIZONTAL) - - # Add info icon - info_icon = wx.ArtProvider.GetBitmap(wx.ART_INFORMATION, wx.ART_MESSAGE_BOX, (32, 32)) - icon_ctrl = wx.StaticBitmap(self, -1, info_icon) - header_sizer.Add(icon_ctrl, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10) - - # Add title - title_text = wx.StaticText(self, label="Privacy and System Information") - title_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) - title_text.SetFont(title_font) - header_sizer.Add(title_text, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10) - - main_sizer.Add(header_sizer, 0, wx.EXPAND) - - # Create scrolled window for the content - self.scrolled_win = wx.ScrolledWindow(self) - self.scrolled_win.SetScrollRate(10, 10) - - scrolled_sizer = wx.BoxSizer(wx.VERTICAL) - - # Get system information - system_info = self.get_system_info() - - # Create system info section - sysinfo_text = wx.StaticText(self.scrolled_win, label="System Information:") - sysinfo_font = wx.Font(11, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) - sysinfo_text.SetFont(sysinfo_font) - scrolled_sizer.Add(sysinfo_text, 0, wx.ALL, 5) - - # System info details - info_details = ( - f"Operating System: {system_info['os']}\n" - f"Architecture: {system_info['architecture']}\n" - f"Processor: {system_info['processor']}\n" - f"Physical Cores: {system_info['physical_cores']}\n" - f"Total Cores: {system_info['total_cores']}\n" - f"Max Frequency: {system_info['max_frequency']} MHz\n" - f"Total RAM: {system_info['total_ram']} GB\n" - f"Available RAM: {system_info['available_ram']} GB\n" - f"Hostname: {system_info['hostname']}\n" - f"Username: {system_info['username']}\n" - f"Python Version: {system_info['python_version']}\n" - f"wxPython Version: {system_info['wx_version']}" - ) - - info_text = wx.StaticText(self.scrolled_win, label=info_details) - scrolled_sizer.Add(info_text, 0, wx.ALL | wx.EXPAND, 10) - - # Add separator - scrolled_sizer.Add(wx.StaticLine(self.scrolled_win), 0, wx.EXPAND | wx.ALL, 10) - - # Security and privacy notice - security_text = wx.StaticText(self.scrolled_win, label="Security and Privacy Notice:") - security_text.SetFont(sysinfo_font) - scrolled_sizer.Add(security_text, 0, wx.ALL, 5) - - # Detect potential security features - security_warnings = self.get_security_warnings(system_info) - - privacy_notice = ( - "wxIRC is an Open Source project and must not be distributed for commercial purposes.\n\n" - - "Security Considerations:\n" - f"{security_warnings}\n\n" - - "Important Reminders:\n" - "Your system contains hardware level security processors that operate independently\n" - "Network traffic may be monitored by various entities\n" - "Never discuss or plan illegal activities through any communication platform\n" - "Keep your system and software updated for security patches\n" - "Use strong, unique passwords and enable 2FA where available\n\n" - - "This application collects no personal data and makes no network connections\n" - "beyond those required for IRC functionality that you explicitly initiate." - ) - - self.body_text = wx.StaticText(self.scrolled_win, label=privacy_notice) - scrolled_sizer.Add(self.body_text, 1, wx.ALL | wx.EXPAND, 10) - - self.scrolled_win.SetSizer(scrolled_sizer) - main_sizer.Add(self.scrolled_win, 1, wx.EXPAND | wx.ALL, 5) - - # Add OK button - ok_btn = wx.Button(self, wx.ID_OK, "I Understand and Continue") - ok_btn.SetDefault() - ok_btn.SetMinSize((160, 35)) - - btn_sizer = wx.BoxSizer(wx.HORIZONTAL) - btn_sizer.AddStretchSpacer() - btn_sizer.Add(ok_btn, 0, wx.ALL, 10) - btn_sizer.AddStretchSpacer() - - main_sizer.Add(btn_sizer, 0, wx.EXPAND) - - self.SetSizer(main_sizer) - - # Center on parent - self.CentreOnParent() - - # Bind events - self.Bind(wx.EVT_BUTTON, self.on_ok, ok_btn) - self.Bind(wx.EVT_SIZE, self.on_resize) - - # Fit the dialog to content - self.Fit() - self.adjust_to_screen() - - def get_system_info(self): - """Gather comprehensive system information with improved processor detection""" - try: - # CPU information - cpu_freq = psutil.cpu_freq() - max_freq = int(cpu_freq.max) if cpu_freq else "N/A" - - # Memory information - memory = psutil.virtual_memory() - total_ram_gb = round(memory.total / (1024**3), 1) - available_ram_gb = round(memory.available / (1024**3), 1) - - # Platform information - system = platform.system() - if system == "Windows": - os_info = f"Windows {platform.release()}" - elif system == "Linux": - # Try to get distro info for Linux - try: - with open('/etc/os-release', 'r') as f: - lines = f.readlines() - for line in lines: - if line.startswith('PRETTY_NAME='): - os_info = line.split('=', 1)[1].strip().strip('"') - break - else: - os_info = f"Linux ({platform.release()})" - except: - os_info = f"Linux ({platform.release()})" - elif system == "Darwin": - os_info = f"macOS {platform.mac_ver()[0]}" - else: - os_info = f"{system} {platform.release()}" - - # Improved processor detection - processor = self.detect_processor() - - return { - 'os': os_info, - 'architecture': platform.architecture()[0], - 'processor': processor, - 'physical_cores': psutil.cpu_count(logical=False), - 'total_cores': psutil.cpu_count(logical=True), - 'max_frequency': max_freq, - 'total_ram': total_ram_gb, - 'available_ram': available_ram_gb, - 'hostname': socket.gethostname(), - 'username': getpass.getuser(), - 'python_version': platform.python_version(), - 'wx_version': wx.VERSION_STRING, - 'platform': platform.platform() - } - except Exception as e: - logger.error(f"Error gathering system info: {e}") - return { - 'os': "Unknown", - 'architecture': "Unknown", - 'processor': "Unknown", - 'physical_cores': "N/A", - 'total_cores': "N/A", - 'max_frequency': "N/A", - 'total_ram': "N/A", - 'available_ram': "N/A", - 'hostname': "Unknown", - 'username': "Unknown", - 'python_version': platform.python_version(), - 'wx_version': wx.VERSION_STRING, - 'platform': "Unknown" - } - - def detect_processor(self): - """Improved processor detection for Ryzen and other CPUs""" - try: - # Method 1: Try platform.processor() first - processor = platform.processor() - if processor and processor.strip() and processor != "unknown": - return processor.strip() - - # Method 2: Try CPU info file on Linux - if platform.system() == "Linux": - try: - with open('/proc/cpuinfo', 'r') as f: - cpuinfo = f.read() - - # Look for model name - for line in cpuinfo.split('\n'): - if line.startswith('model name') or line.startswith('Model Name') or line.startswith('Processor'): - parts = line.split(':', 1) - if len(parts) > 1: - cpu_name = parts[1].strip() - if cpu_name: - return cpu_name - - # Look for hardware field - for line in cpuinfo.split('\n'): - if line.startswith('Hardware'): - parts = line.split(':', 1) - if len(parts) > 1: - cpu_name = parts[1].strip() - if cpu_name: - return cpu_name - - except Exception as e: - logger.debug(f"Error reading /proc/cpuinfo: {e}") - - # Method 3: Try lscpu command - try: - result = subprocess.run(['lscpu'], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - for line in result.stdout.split('\n'): - if 'Model name:' in line: - parts = line.split(':', 1) - if len(parts) > 1: - cpu_name = parts[1].strip() - if cpu_name: - return cpu_name - except Exception as e: - logger.debug(f"Error running lscpu: {e}") - - # Method 4: Try sysctl on macOS - if platform.system() == "Darwin": - try: - result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'], - capture_output=True, text=True, timeout=5) - if result.returncode == 0: - cpu_name = result.stdout.strip() - if cpu_name: - return cpu_name - except Exception as e: - logger.debug(f"Error running sysctl: {e}") - - # Method 5: Try WMI on Windows - if platform.system() == "Windows": - try: - import ctypes - from ctypes import wintypes - - # Try kernel32 GetNativeSystemInfo - kernel32 = ctypes.windll.kernel32 - - class SYSTEM_INFO(ctypes.Structure): - _fields_ = [ - ("wProcessorArchitecture", wintypes.WORD), - ("wReserved", wintypes.WORD), - ("dwPageSize", wintypes.DWORD), - ("lpMinimumApplicationAddress", ctypes.c_void_p), - ("lpMaximumApplicationAddress", ctypes.c_void_p), - ("dwActiveProcessorMask", ctypes.c_void_p), - ("dwNumberOfProcessors", wintypes.DWORD), - ("dwProcessorType", wintypes.DWORD), - ("dwAllocationGranularity", wintypes.DWORD), - ("wProcessorLevel", wintypes.WORD), - ("wProcessorRevision", wintypes.WORD) - ] - - system_info = SYSTEM_INFO() - kernel32.GetNativeSystemInfo(ctypes.byref(system_info)) - - # Map processor type to name - processor_types = { - 586: "Pentium", - 8664: "x64", - } - - if system_info.dwProcessorType in processor_types: - return processor_types[system_info.dwProcessorType] - - except Exception as e: - logger.debug(f"Error with Windows processor detection: {e}") - - # Final fallback - return f"Unknown ({platform.machine()})" - - except Exception as e: - logger.error(f"Error in processor detection: {e}") - return "Unknown" - - def get_security_warnings(self, system_info): - """Generate security warnings based on system capabilities""" - warnings = [] - - try: - # Check for Intel ME / AMD PSP indicators - processor_lower = system_info['processor'].lower() - - if 'intel' in processor_lower: - warnings.append("Intel Processor detected: Management Engine (ME) present") - if 'core' in processor_lower and any(gen in processor_lower for gen in ['i3', 'i5', 'i7', 'i9']): - warnings.append("Modern Intel Core processor: ME capabilities active") - - elif 'amd' in processor_lower: - warnings.append("AMD Processor detected: Platform Security Processor (PSP) present") - if 'ryzen' in processor_lower: - warnings.append("Modern AMD Ryzen processor: PSP capabilities active") - if '5600x' in processor_lower: - warnings.append("AMD Ryzen 5 5600X: Zen 3 architecture with PSP") - - # Check for specific AMD models - if '5600x' in processor_lower: - warnings.append("AMD Ryzen 5 5600X detected: Hardware level security processor active") - - # Check RAM size - if system_info['total_ram'] != "N/A" and system_info['total_ram'] > 8: - warnings.append("System has substantial RAM capacity for background processes") - - # Check core count - if system_info['total_cores'] != "N/A" and system_info['total_cores'] >= 4: - warnings.append("Multi core system capable of parallel background processing") - - # Network capabilities - warnings.append("System has network connectivity capabilities") - warnings.append("WiFi/Ethernet hardware present for data transmission") - - # Operating system specific - os_lower = system_info['os'].lower() - if 'windows' in os_lower: - warnings.append("Windows OS: Telemetry and background services active") - elif 'linux' in os_lower or 'arch' in os_lower: - warnings.append("Linux OS: Generally more transparent, but hardware level components still active") - if 'arch' in os_lower: - warnings.append("Arch Linux: Rolling release, ensure regular security updates") - elif 'mac' in os_lower: - warnings.append("macOS: Apple T2/T1 security chip or Apple Silicon present") - - except Exception as e: - logger.error(f"Error generating security warnings: {e}") - warnings = ["Unable to fully assess system security features"] - - return "\n".join(warnings) - - def adjust_to_screen(self): - """Adjust dialog size and position to fit within screen bounds""" - screen_width, screen_height = wx.DisplaySize() - current_size = self.GetSize() - - final_width = min(current_size.width, screen_width - 40) - final_height = min(current_size.height, screen_height - 40) - - self.SetSize(final_width, final_height) - self.body_text.Wrap(final_width - 60) - self.Layout() - self.scrolled_win.FitInside() - self.CentreOnParent() - - def on_resize(self, event): - """Handle dialog resize to re-wrap text appropriately""" - try: - if hasattr(self, 'body_text'): - new_width = self.GetSize().width - 60 - if new_width > 200: - self.body_text.Wrap(new_width) - self.scrolled_win.Layout() - self.scrolled_win.FitInside() - except Exception as e: - logger.error(f"Error in resize handler: {e}") - event.Skip() - - def on_ok(self, event): - self.EndModal(wx.ID_OK) - class UIUpdate: """Thread-safe UI update container""" def __init__(self, callback, *args, **kwargs): @@ -425,454 +38,6 @@ class UIUpdate: self.args = args self.kwargs = kwargs -class SearchDialog(wx.Dialog): - def __init__(self, parent): - super().__init__(parent, title="Search", style=wx.DEFAULT_DIALOG_STYLE) - self.parent = parent - - sizer = wx.BoxSizer(wx.VERTICAL) - - # Search input - search_sizer = wx.BoxSizer(wx.HORIZONTAL) - search_sizer.Add(wx.StaticText(self, label="Search:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5) - self.search_ctrl = wx.TextCtrl(self, size=(200, -1), style=wx.TE_PROCESS_ENTER) - self.search_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_search) - search_sizer.Add(self.search_ctrl, 1, wx.EXPAND | wx.ALL, 5) - - sizer.Add(search_sizer, 0, wx.EXPAND) - - # Options - options_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.case_sensitive = wx.CheckBox(self, label="Case sensitive") - self.whole_word = wx.CheckBox(self, label="Whole word") - options_sizer.Add(self.case_sensitive, 0, wx.ALL, 5) - options_sizer.Add(self.whole_word, 0, wx.ALL, 5) - - sizer.Add(options_sizer, 0, wx.EXPAND) - - # Buttons - btn_sizer = wx.StdDialogButtonSizer() - self.search_btn = wx.Button(self, wx.ID_OK, "Search") - self.search_btn.SetDefault() - self.search_btn.Bind(wx.EVT_BUTTON, self.on_search) - btn_sizer.AddButton(self.search_btn) - - close_btn = wx.Button(self, wx.ID_CANCEL, "Close") - btn_sizer.AddButton(close_btn) - btn_sizer.Realize() - - sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) - - self.SetSizer(sizer) - self.Fit() - - self.search_ctrl.SetFocus() - - def on_search(self, event): - search_text = self.search_ctrl.GetValue().strip() - if search_text: - self.parent.perform_search( - search_text, - self.case_sensitive.IsChecked(), - self.whole_word.IsChecked() - ) - -class AboutDialog(wx.Dialog): - def __init__(self, parent): - super().__init__(parent, title="About wxIRC Client", style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) - - sizer = wx.BoxSizer(wx.VERTICAL) - - # Application info - info_text = wx.StaticText(self, label="wxIRC Client") - info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) - info_text.SetFont(info_font) - - version_text = wx.StaticText(self, label=f"V 1.1.1.0") - version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) - version_text.SetFont(version_font) - - features_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) - - # Add to sizer - sizer.Add(info_text, 0, wx.ALL | wx.ALIGN_CENTER, 10) - sizer.Add(version_text, 0, wx.ALL | wx.ALIGN_CENTER, 5) - - # OK button - ok_btn = wx.Button(self, wx.ID_OK, "OK") - ok_btn.SetDefault() - btn_sizer = wx.BoxSizer(wx.HORIZONTAL) - btn_sizer.Add(ok_btn, 0, wx.ALIGN_CENTER | wx.ALL, 10) - sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER) - - self.SetSizer(sizer) - self.Fit() - self.Centre() - -class IRCPanel(wx.Panel): - def __init__(self, parent, main_frame): - super().__init__(parent) - self.parent = parent - self.main_frame = main_frame - self.messages = [] - - sizer = wx.BoxSizer(wx.VERTICAL) - - # Use a better font for chat with white theme - self.text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.TE_AUTO_URL) - - # White theme colors - self.text_ctrl.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background - self.text_ctrl.SetForegroundColour(wx.Colour(0, 0, 0)) # Black text - - # Load appropriate font - self.font = self.load_system_font() - self.text_ctrl.SetFont(self.font) - - sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0) - - input_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.input_ctrl = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) - self.input_ctrl.SetHint("Type message here …") - self.input_ctrl.Bind(wx.EVT_TEXT_ENTER, self.on_send) - self.input_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_key_down) - - send_btn = wx.Button(self, label="Send") - send_btn.SetToolTip("Send message (Enter)") - send_btn.Bind(wx.EVT_BUTTON, self.on_send) - - input_sizer.Add(self.input_ctrl, 1, wx.EXPAND | wx.ALL, 2) - input_sizer.Add(send_btn, 0, wx.ALL, 2) - - sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0) - - self.SetSizer(sizer) - self.target = None - self.history = [] - self.history_pos = -1 - - # Search state - self.search_text = "" - self.search_positions = [] - self.current_search_index = -1 - - # Bind Ctrl+F for search - self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down) - accel_tbl = wx.AcceleratorTable([(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)]) - self.SetAcceleratorTable(accel_tbl) - self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND) - - def load_system_font(self): - """Load appropriate system font with high DPI support""" - try: - # Get system DPI scale factor - dc = wx.ClientDC(self) - dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI - - # Calculate base font size based on DPI - base_size = 10 - if dpi_scale > 1.5: - font_size = int(base_size * 1.5) # 150% scaling - elif dpi_scale > 1.25: - font_size = int(base_size * 1.25) # 125% scaling - else: - font_size = base_size - - # Try system fonts in order of preference - font_families = [ - (wx.FONTFAMILY_TELETYPE, "Consolas"), - (wx.FONTFAMILY_TELETYPE, "Courier New"), - (wx.FONTFAMILY_TELETYPE, "Monaco"), - (wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"), - (wx.FONTFAMILY_TELETYPE, "Liberation Mono"), - ] - - for family, face_name in font_families: - font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name) - if font.IsOk(): - logger.info(f"Using font: {face_name} at {font_size}pt") - return font - - # Fallback to default monospace - font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) - logger.info("Using system monospace font as fallback") - return font - - except Exception as e: - logger.error(f"Error loading system font: {e}") - # Ultimate fallback - return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) - - def set_target(self, target): - self.target = target - - def add_message(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): - """Thread-safe message addition with username coloring""" - try: - # Use CallAfter for thread safety - if wx.IsMainThread(): - self._add_message_safe(message, username, username_color, message_color, bold, italic, underline) - else: - wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline) - except Exception as e: - logger.error(f"Error in add_message: {e}") - - def _add_message_safe(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): - """Actually add message - must be called from main thread""" - try: - self.messages.append(message) - - # Save current position for formatting - start_pos = self.text_ctrl.GetLastPosition() - - if username and username_color: - # Add username with its color - attr = wx.TextAttr() - attr.SetTextColour(username_color) - if bold: - attr.SetFontWeight(wx.FONTWEIGHT_BOLD) - if italic: - attr.SetFontStyle(wx.FONTSTYLE_ITALIC) - if underline: - attr.SetFontUnderlined(True) - attr.SetFont(self.font) - self.text_ctrl.SetDefaultStyle(attr) - self.text_ctrl.AppendText(username) - - # Add the rest of the message with message color - attr = wx.TextAttr() - if message_color: - attr.SetTextColour(message_color) - else: - attr.SetTextColour(wx.Colour(0, 0, 0)) # Black text for white theme - - attr.SetFont(self.font) - self.text_ctrl.SetDefaultStyle(attr) - - # Append the message (without username if we already added it) - if username and username_color: - # Find the message part after username - message_text = message[message.find(username) + len(username):] - self.text_ctrl.AppendText(message_text + "\n") - else: - self.text_ctrl.AppendText(message + "\n") - - # Auto-scroll to bottom - self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition()) - except Exception as e: - logger.error(f"Error adding message safely: {e}") - - def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): - """Add a formatted message with colored username""" - try: - if is_action: - message = f"{timestamp}* {username} {content}" - self.add_message(message, f"* {username}", username_color, wx.Colour(128, 0, 128), italic=True) # Dark purple for actions - else: - message = f"{timestamp}<{username}> {content}" - self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text - except Exception as e: - logger.error(f"Error adding formatted message: {e}") - - def add_system_message(self, message, color=None, bold=False): - """Add system message without username coloring""" - try: - if color is None: - color = wx.Colour(0, 0, 128) # Dark blue for system messages - self.add_message(message, None, None, color, bold, False, False) - except Exception as e: - logger.error(f"Error adding system message: {e}") - - def on_text_key_down(self, event): - """Handle key events in the text control""" - keycode = event.GetKeyCode() - if event.ControlDown() and keycode == ord('F'): - self.on_search(event) - else: - event.Skip() - - def on_search(self, event): - """Open search dialog""" - try: - dlg = SearchDialog(self) - dlg.ShowModal() - dlg.Destroy() - except Exception as e: - logger.error(f"Error in search: {e}") - - def perform_search(self, search_text, case_sensitive=False, whole_word=False): - """Perform text search in the chat history""" - try: - self.search_text = search_text - self.search_positions = [] - self.current_search_index = -1 - - # Get all text - full_text = self.text_ctrl.GetValue() - if not full_text or not search_text: - return - - # Prepare search parameters - search_flags = 0 - if not case_sensitive: - # For manual search, we'll handle case sensitivity ourselves - search_text_lower = search_text.lower() - full_text_lower = full_text.lower() - - # Manual search implementation since wx.TextCtrl doesn't have FindText - pos = 0 - while pos < len(full_text): - if case_sensitive: - # Case sensitive search - found_pos = full_text.find(search_text, pos) - else: - # Case insensitive search - found_pos = full_text_lower.find(search_text_lower, pos) - - if found_pos == -1: - break - - # For whole word search, verify boundaries - if whole_word: - # Check if it's a whole word - is_word_start = (found_pos == 0 or not full_text[found_pos-1].isalnum()) - is_word_end = (found_pos + len(search_text) >= len(full_text) or - not full_text[found_pos + len(search_text)].isalnum()) - - if is_word_start and is_word_end: - self.search_positions.append(found_pos) - pos = found_pos + 1 # Move forward to avoid infinite loop - else: - self.search_positions.append(found_pos) - pos = found_pos + len(search_text) - - if self.search_positions: - self.current_search_index = 0 - self.highlight_search_result() - self.main_frame.SetStatusText(f"Found {len(self.search_positions)} occurrences of '{search_text}'") - else: - self.main_frame.SetStatusText(f"Text '{search_text}' not found") - wx.Bell() - - except Exception as e: - logger.error(f"Error performing search: {e}") - traceback.print_exc() - - def highlight_search_result(self): - """Highlight the current search result""" - try: - if not self.search_positions or self.current_search_index < 0: - return - - pos = self.search_positions[self.current_search_index] - - # Select the found text - self.text_ctrl.SetSelection(pos, pos + len(self.search_text)) - self.text_ctrl.ShowPosition(pos) - - # Update status - self.main_frame.SetStatusText( - f"Found {self.current_search_index + 1} of {len(self.search_positions)}: '{self.search_text}'" - ) - except Exception as e: - logger.error(f"Error highlighting search result: {e}") - - def find_next(self): - """Find next occurrence""" - if self.search_positions: - self.current_search_index = (self.current_search_index + 1) % len(self.search_positions) - self.highlight_search_result() - - def find_previous(self): - """Find previous occurrence""" - if self.search_positions: - self.current_search_index = (self.current_search_index - 1) % len(self.search_positions) - self.highlight_search_result() - - def on_key_down(self, event): - try: - keycode = event.GetKeyCode() - if keycode == wx.WXK_UP: - if self.history and self.history_pos < len(self.history) - 1: - self.history_pos += 1 - self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) - elif keycode == wx.WXK_DOWN: - if self.history_pos > 0: - self.history_pos -= 1 - self.input_ctrl.SetValue(self.history[-(self.history_pos + 1)]) - elif self.history_pos == 0: - self.history_pos = -1 - self.input_ctrl.Clear() - elif keycode == wx.WXK_TAB: - # Tab completion for nicknames - self.handle_tab_completion() - return # Don't skip to prevent default tab behavior - elif keycode == wx.WXK_F3: - # F3 for find next - if self.search_positions: - self.find_next() - return - elif event.ShiftDown() and keycode == wx.WXK_F3: - # Shift+F3 for find previous - if self.search_positions: - self.find_previous() - return - else: - event.Skip() - except Exception as e: - logger.error(f"Error in key handler: {e}") - event.Skip() - - def handle_tab_completion(self): - """Handle tab completion for nicknames""" - try: - current_text = self.input_ctrl.GetValue() - if not current_text or not self.target or not self.target.startswith('#'): - return - - users = self.main_frame.channel_users.get(self.target, []) - if not users: - return - - # Find word at cursor position - pos = self.input_ctrl.GetInsertionPoint() - text_before = current_text[:pos] - words = text_before.split() - - if not words: - return - - current_word = words[-1] - - # Find matching nicks - matches = [user for user in users if user.lower().startswith(current_word.lower())] - - if matches: - if len(matches) == 1: - # Single match - complete it - new_word = matches[0] - if ':' in text_before or text_before.strip().endswith(current_word): - # Replace the current word - new_text = text_before[:-len(current_word)] + new_word + current_text[pos:] - self.input_ctrl.SetValue(new_text) - self.input_ctrl.SetInsertionPoint(pos - len(current_word) + len(new_word)) - else: - # Multiple matches - show in status - self.main_frame.SetStatusText(f"Tab completion: {', '.join(matches[:5])}{'...' if len(matches) > 5 else ''}") - except Exception as e: - logger.error(f"Error in tab completion: {e}") - - def on_send(self, event): - try: - message = self.input_ctrl.GetValue().strip() - if message and self.target: - self.history.append(message) - self.history_pos = -1 - self.main_frame.send_message(self.target, message) - self.input_ctrl.Clear() - except Exception as e: - logger.error(f"Error sending message: {e}") - class IRCFrame(wx.Frame): def __init__(self): super().__init__(None, title="wxIRC", size=(1200, 700)) @@ -903,6 +68,9 @@ class IRCFrame(wx.Frame): self.away = False self.timestamps = True + # Notes data - Add this + self.notes_data = defaultdict(dict) + # User color mapping - darker colors for white theme self.user_colors = {} self.available_colors = [ @@ -943,7 +111,7 @@ class IRCFrame(wx.Frame): (wx.ACCEL_CTRL, ord('F'), wx.ID_FIND), (wx.ACCEL_NORMAL, wx.WXK_F3, 1001), (wx.ACCEL_SHIFT, wx.WXK_F3, 1002), - (wx.ACCEL_CTRL, wx.WXK_ESCAPE, 1003), # Quick Escape + (wx.ACCEL_SHIFT, wx.WXK_ESCAPE, 1003), # Quick Escape ]) self.SetAcceleratorTable(accel_tbl) self.Bind(wx.EVT_MENU, self.on_global_search, id=wx.ID_FIND) @@ -996,7 +164,7 @@ class IRCFrame(wx.Frame): current_panel.find_previous() def on_quick_escape(self, event): - """Handle Ctrl+Esc for quick escape - exit immediately""" + """Handle Shift+Esc for quick escape - exit immediately""" try: # Stop UI timer first if self.ui_timer and self.ui_timer.IsRunning(): @@ -1061,6 +229,12 @@ class IRCFrame(wx.Frame): self.connect_btn.Bind(wx.EVT_BUTTON, self.on_connect) conn_box_sizer.Add(self.connect_btn, 0, wx.EXPAND | wx.ALL, 5) + # Add Notes button to connection box +# self.notes_btn = wx.Button(conn_box, label="Notes") +# self.notes_btn.SetToolTip("Open notes editor") +# self.notes_btn.Bind(wx.EVT_BUTTON, self.on_notes) +# conn_box_sizer.Add(self.notes_btn, 0, wx.EXPAND | wx.ALL, 5) + left_sizer.Add(conn_box_sizer, 0, wx.EXPAND | wx.ALL, 5) # Channel management @@ -1184,6 +358,7 @@ class IRCFrame(wx.Frame): # Tools menu tools_menu = wx.Menu() + tools_menu.Append(208, "&Notes\tCtrl+T") # Add Notes menu item tools_menu.Append(201, "&WHOIS User\tCtrl+I") tools_menu.Append(202, "Change &Nick\tCtrl+N") tools_menu.AppendSeparator() @@ -1194,6 +369,7 @@ class IRCFrame(wx.Frame): tools_menu.Append(205, "Set &Highlights") tools_menu.Append(206, "Auto-join Channels") tools_menu.Append(207, "Command Help") + self.Bind(wx.EVT_MENU, self.on_notes, id=208) # Bind Notes menu item self.Bind(wx.EVT_MENU, self.on_menu_whois, id=201) self.Bind(wx.EVT_MENU, self.on_menu_change_nick, id=202) self.Bind(wx.EVT_MENU, self.on_menu_away, id=203) @@ -1219,7 +395,41 @@ class IRCFrame(wx.Frame): dlg.Destroy() except Exception as e: logger.error(f"Error showing about dialog: {e}") - + + def on_notes(self, event): + """Open notes editor dialog""" + try: + # Check if notes window already exists + if hasattr(self, 'notes_frame') and self.notes_frame: + try: + self.notes_frame.Raise() # Bring to front if already open + return + except: + # Frame was destroyed, create new one + pass + + self.notes_frame = NotesDialog(self, self.notes_data) + self.notes_frame.Bind(wx.EVT_CLOSE, self.on_notes_closed) + self.notes_frame.Show() + + except Exception as e: + logger.error(f"Error opening notes dialog: {e}") + self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0)) + + def on_notes_closed(self, event=None): + """Handle notes frame closing""" + if hasattr(self, 'notes_frame') and self.notes_frame: + try: + # Update notes data from the frame before it closes + self.notes_data = self.notes_frame.notes_data + except Exception as e: + logger.error(f"Error getting notes data on close: {e}") + finally: + self.notes_frame = None + + if event: + event.Skip() # Allow the event to propagate + def setup_irc_handlers(self): try: self.reactor = irc.client.Reactor() @@ -1355,7 +565,7 @@ class IRCFrame(wx.Frame): self.is_connecting = False self.connect_btn.SetLabel("Disconnect") self.connect_btn.Enable(True) - self.SetStatusText(f"Connected to {self.server} - Use /help for commands, Ctrl+F to search, Ctrl+Esc to quick exit") + self.SetStatusText(f"Connected to {self.server} - Use /help for commands, Ctrl+F to search, Shift+Esc to quick exit") logger.info(f"Successfully connected to {self.server}") def on_connect_failed(self, error_msg): @@ -1779,7 +989,7 @@ BASIC USAGE: - Use Up/Down arrows for message history - Tab for nickname completion in channels - Ctrl+F to search in chat history -- Ctrl+Esc to quickly exit the application +- Shift+Esc to quickly exit the application TEXT FORMATTING: - Usernames are colored for easy identification @@ -2032,6 +1242,9 @@ COMMANDS (type /help in chat for full list): if self.ui_timer and self.ui_timer.IsRunning(): self.ui_timer.Stop() + # Notes data will be lost when app closes (RAM only) + # User can save to file if they want persistence + if self.is_connected(): self.disconnect() # Give it a moment to disconnect gracefully @@ -2046,6 +1259,7 @@ if __name__ == "__main__": try: app = wx.App() frame = IRCFrame() + frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO)) app.MainLoop() except Exception as e: logger.critical(f"Fatal error: {e}")