13 Commits

Author SHA1 Message Date
00c111da73 some cleanup, seperating classes into their own files. 2026-03-11 12:14:30 +01:00
7301186102 icon for server 2025-12-15 12:59:28 +01:00
seppmutterman@gmail.com
f1ed8d36c4 fixed the build script 2025-12-14 18:47:17 +01:00
seppmutterman@gmail.com
69b10b0864 testing stuff ; testing branch lol 2025-12-14 18:35:51 +01:00
faeac6c96f some sound additions 2025-12-12 21:15:07 +01:00
35dfedd5c9 Add command autocomplete feature to IRCPanel
- Introduced CommandAutocomplete class for command suggestions.
- Enhanced IRCPanel to show command suggestions while typing.
- Improved error handling and user feedback for command execution.
- Updated message handling to support formatted text display.
2025-12-06 17:21:41 +01:00
b4c74f098b better kaomojis 2025-12-06 17:02:19 +01:00
61a2458f83 update to be better ; still have to fix the scrolling on windows 2025-12-03 13:56:30 +01:00
d5a4628281 update the kaomoji / emote selector 2025-12-02 07:34:29 +01:00
5456b6c5fd some cute emotes 2025-12-01 16:03:48 +01:00
6bdf31cb26 local server, useful for local things. Start a IRC Server on your LAN 2025-11-28 13:03:13 +01:00
8737720af6 Merge pull request 'fixed stuff with theming, should look the same on windows and linux now!' (#1) from theme-consistency into main
Reviewed-on: #1

lgtm
2025-11-27 16:57:06 +00:00
0c7b2d3bdb fixed stuff with theming, should look the same on windows and linux now! 2025-11-27 17:21:03 +01:00
22 changed files with 2433 additions and 622 deletions

View File

@@ -10,8 +10,11 @@ pyinstaller `
--hidden-import wx._xml ` --hidden-import wx._xml `
--add-data "FiraCode-Regular.ttf;." ` --add-data "FiraCode-Regular.ttf;." `
--add-data "FiraCode-SemiBold.ttf;." ` --add-data "FiraCode-SemiBold.ttf;." `
--add-data "src\sounds\*;sounds" `
--add-data "venv\Lib\site-packages\irc\codes.txt;irc" ` --add-data "venv\Lib\site-packages\irc\codes.txt;irc" `
--add-data "icon.ico;." ` --add-data "icon.ico;." `
--add-data "src\channel.ico;." `
--add-data "src\server.ico;." `
--icon "icon.ico" ` --icon "icon.ico" `
"src/main.py" "src/main.py"

View File

@@ -31,31 +31,20 @@ class AboutDialog(wx.Dialog):
info_text = wx.StaticText(self, label="wxIRC Client") info_text = wx.StaticText(self, label="wxIRC Client")
info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD) info_font = wx.Font(14, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)
info_text.SetFont(info_font) info_text.SetFont(info_font)
sizer.Add(info_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
import base64, hashlib version_text = wx.StaticText(self, label="Version : 0.2.3") # COMMIT_HUNDRED:COMMIT_TEN:COMMIT_ONE e.g 23 commits -> 0.2.3
self.encoded = base64.b64encode(
hashlib.sha256(f"{self.GetId()}-{self.GetHandle()}".encode()).digest()
).decode('utf-8')
version_text = wx.StaticText(self, label=f"wxIRC V:e1")
version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) version_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
version_text.SetFont(version_font) version_text.SetFont(version_font)
rand_hash = wx.StaticText(self, label=f"{self.encoded}")
rand_hash.SetFont(version_font)
contrubutors_text = wx.StaticText(self, label="This software may not be used for commercial purposes. \n And may not be distributed for commercial purposes.")
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(version_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
sizer.Add(rand_hash, 0, wx.ALL | wx.ALIGN_CENTER, 5)
sizer.Add(contrubutors_text, 0, wx.ALL | wx.ALIGN_CENTER, 5) contributors_text = wx.StaticText(self, label="""
Contributors:
* rattatwinko (rattatwinko.servecounterstrike.com)
""")
contributors_font = wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
contributors_text.SetFont(contributors_font)
sizer.Add(contributors_text, 0, wx.ALL | wx.ALIGN_CENTER, 5)
# OK button # OK button
ok_btn = wx.Button(self, wx.ID_OK, "OK") ok_btn = wx.Button(self, wx.ID_OK, "OK")

161
src/CommandAutocomplete.py Normal file
View File

@@ -0,0 +1,161 @@
import wx
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class CommandAutocomplete(wx.PopupTransientWindow):
"""Popup window for IRC command autocomplete, similar to Minecraft."""
def __init__(self, parent, commands, on_select_callback):
super().__init__(parent, wx.BORDER_SIMPLE)
self.on_select_callback = on_select_callback
self.commands = commands # List of (command, description) tuples
self.filtered_commands = commands.copy()
self.selected_index = 0
self._init_ui()
self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy)
def _init_ui(self):
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Command list
self.list_ctrl = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_NO_HEADER)
self.list_ctrl.InsertColumn(0, "Command", width=120)
self.list_ctrl.InsertColumn(1, "Description", width=280)
self._update_list()
# Bind events
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated)
self.list_ctrl.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_item_selected)
self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook)
self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus)
main_sizer.Add(self.list_ctrl, 1, wx.EXPAND | wx.ALL, 2)
panel.SetSizer(main_sizer)
main_sizer.Fit(panel)
self.SetClientSize(panel.GetBestSize())
# Select first item
if self.list_ctrl.GetItemCount() > 0:
self.list_ctrl.Select(0)
self.selected_index = 0
def _update_list(self):
"""Update the list with filtered commands."""
self.list_ctrl.DeleteAllItems()
for cmd, desc in self.filtered_commands:
idx = self.list_ctrl.InsertItem(self.list_ctrl.GetItemCount(), cmd)
self.list_ctrl.SetItem(idx, 1, desc)
# Resize to fit content (max 8 items visible)
item_height = 20
max_items = min(8, len(self.filtered_commands))
self.list_ctrl.SetSize((410, item_height * max_items + 4))
self.SetClientSize((410, item_height * max_items + 4))
def filter_commands(self, search_text):
"""Filter commands based on search text."""
search_lower = search_text.lower().strip()
if not search_lower:
self.filtered_commands = self.commands.copy()
else:
self.filtered_commands = [
(cmd, desc) for cmd, desc in self.commands
if cmd.lower().startswith(search_lower)
]
self.selected_index = 0
self._update_list()
if self.list_ctrl.GetItemCount() > 0:
self.list_ctrl.Select(0)
def on_item_activated(self, event):
"""Handle double-click or Enter on item."""
idx = event.GetIndex()
if 0 <= idx < len(self.filtered_commands):
cmd, _ = self.filtered_commands[idx]
if self.on_select_callback:
self.on_select_callback(cmd)
self.safe_dismiss()
def on_item_selected(self, event):
"""Handle item selection."""
self.selected_index = event.GetIndex()
def select_next(self):
"""Select next item."""
if self.list_ctrl.GetItemCount() > 0:
self.selected_index = (self.selected_index + 1) % self.list_ctrl.GetItemCount()
self.list_ctrl.Select(self.selected_index)
self.list_ctrl.EnsureVisible(self.selected_index)
def select_previous(self):
"""Select previous item."""
if self.list_ctrl.GetItemCount() > 0:
self.selected_index = (self.selected_index - 1) % self.list_ctrl.GetItemCount()
self.list_ctrl.Select(self.selected_index)
self.list_ctrl.EnsureVisible(self.selected_index)
def get_selected_command(self):
"""Get the currently selected command."""
if 0 <= self.selected_index < len(self.filtered_commands):
return self.filtered_commands[self.selected_index][0]
return None
def on_char_hook(self, event):
"""Handle keyboard events."""
keycode = event.GetKeyCode()
if keycode == wx.WXK_ESCAPE:
self.safe_dismiss()
elif keycode == wx.WXK_UP:
self.select_previous()
elif keycode == wx.WXK_DOWN:
self.select_next()
elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER:
cmd = self.get_selected_command()
if cmd and self.on_select_callback:
self.on_select_callback(cmd)
self.safe_dismiss()
else:
event.Skip()
def on_kill_focus(self, event):
"""Handle focus loss."""
focused = wx.Window.FindFocus()
if focused and (focused == self.list_ctrl or self.IsDescendant(focused)):
event.Skip()
return
wx.CallLater(100, self.safe_dismiss)
event.Skip()
def on_destroy(self, event):
event.Skip()
def safe_dismiss(self):
"""Safely dismiss the popup."""
if not self.IsBeingDeleted() and self.IsShown():
try:
self.Dismiss()
except Exception as e:
logger.error(f"Error dismissing command autocomplete: {e}")
def show_at_input(self, input_ctrl):
"""Show popup near the input control."""
input_screen_pos = input_ctrl.ClientToScreen((0, 0))
popup_size = self.GetSize()
display_size = wx.GetDisplaySize()
# Show above the input, otherwise below
if input_screen_pos.y - popup_size.height > 0:
popup_y = input_screen_pos.y - popup_size.height - 2
else:
popup_y = input_screen_pos.y + input_ctrl.GetSize().height + 2
# Keep popup on screen horizontally
popup_x = max(10, min(input_screen_pos.x, display_size.x - popup_size.width - 10))
self.Position((popup_x, popup_y), (0, 0))
self.Popup()

View File

@@ -1,368 +1,367 @@
import wx import wx
import threading import wx.adv
import random
import logging import logging
from SearchDialog import SearchDialog from SearchDialog import SearchDialog
import traceback import traceback
import KaomojiPicker as KaomojiPicker
import CommandAutocomplete as CommandAutocomplete
# Set up logging # Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class IRCPanel(wx.Panel): class IRCPanel(wx.Panel):
# IRC commands with descriptions
IRC_COMMANDS = [
("help", "Show available commands"),
("join", "Join a channel - /join <channel>"),
("part", "Leave current or specified channel - /part [channel]"),
("msg", "Send private message - /msg <nick> <message>"),
("me", "Send action message - /me <action>"),
("nick", "Change nickname - /nick <newnick>"),
("whois", "Get user information - /whois <nick>"),
("topic", "Get or set channel topic - /topic [newtopic]"),
("kick", "Kick user from channel - /kick <user> [reason]"),
("away", "Set away status - /away [message]"),
("quit", "Disconnect from server - /quit [message]"),
]
# Default kaomoji groups
KAOMOJI_GROUPS = {
"Happy": [
":-)", ":)", ":-D", ":D", "^_^", "^o^", "(*^_^*)", "( ^_^)",
"(:3)", "=)", "=]", "^.^", "UwU", "OwO", "^w^", "^u^", "x3",
":3", ":3c", "nya~", "n_n", "(>ω<)", ":33", "^3^"
],
"Sad": [
":-(", ":'(", "T_T", ";_;", ">_<", "(-_-)", "(_ _)"
],
"Angry": [
">:(", ">:-(", ">:-O", ">.<", "(-_-)#"
],
"Love": [
"<3", "(*^3^)", "(^^)v", "(^_^)", "*^_^*"
],
"Surprised": [
":-O", ":O", ":-0", "O_O", "o_O", "O_o"
],
"Sleepy": [
"-_-", "(-.-) zzz", "(~_~)", "zzz"
],
"Shrug": [
r"¯\_(._.)_/¯", "(¬_¬)", "(*_*)", "(>_>)", "(<_<)",
"(o_O)", "(O_o)", "('_')"
],
"Blush": [
"^///^", "(//▽//)", "(*^///^*)", ">///<", "^_^;",
"^///^;", "(*^▽^*)", "(*´▽`*)"
],
"Other": [
"owo", "rawr", ":33p", "xD", ";-)", ";)", ":-P", ":P", ":-|", ":|"
]
}
def __init__(self, parent, main_frame): def __init__(self, parent, main_frame):
super().__init__(parent) super().__init__(parent)
self.parent = parent self.parent = parent
self.main_frame = main_frame self.main_frame = main_frame
self.messages = [] 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.target = None
self.history = [] self.history = []
self.history_pos = -1 self.history_pos = -1
self.current_popup = None
self.command_popup = None
# Search state # Search state
self.search_text = "" self.search_text = ""
self.search_positions = [] self.search_positions = []
self.current_search_index = -1 self.current_search_index = -1
# Bind Ctrl+F for search # Theme setup
self.theme = getattr(self.main_frame, "theme", None)
self.default_text_colour = self.theme["text"] if self.theme else wx.Colour(0, 0, 0)
self._init_ui()
self._setup_accelerators()
def _init_ui(self):
"""Initialize the UI components."""
sizer = wx.BoxSizer(wx.VERTICAL)
# Text display - use TE_RICH2 for styled text support (especially on Windows)
self.text_ctrl = wx.TextCtrl(
self,
style=wx.TE_READONLY | wx.TE_MULTILINE | wx.TE_AUTO_URL | wx.TE_RICH2
)
self.text_ctrl.SetFont(self._load_system_font())
self._apply_theme()
self.text_ctrl.Bind(wx.EVT_KEY_DOWN, self.on_text_key_down) 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)])
sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 0)
# Input area
input_sizer = self._create_input_area()
sizer.Add(input_sizer, 0, wx.EXPAND | wx.ALL, 0)
self.SetSizer(sizer)
def _create_input_area(self):
"""Create the input controls."""
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
# Text input
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)
self.input_ctrl.Bind(wx.EVT_TEXT, self.on_input_text_change)
# Kaomoji button
random_kaomoji = self._get_random_kaomoji()
self.kaomoji_btn = wx.Button(self, label=random_kaomoji)
self.kaomoji_btn.SetToolTip(f"Kaomojis {random_kaomoji}")
self.kaomoji_btn.Bind(wx.EVT_BUTTON, self.on_pick_kaomoji)
# Send button
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(self.kaomoji_btn, 0, wx.ALL, 2)
input_sizer.Add(send_btn, 0, wx.ALL, 2)
return input_sizer
def _setup_accelerators(self):
"""Setup keyboard shortcuts."""
accel_tbl = wx.AcceleratorTable([
(wx.ACCEL_CTRL, ord('F'), wx.ID_FIND)
])
self.SetAcceleratorTable(accel_tbl) self.SetAcceleratorTable(accel_tbl)
self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND) self.Bind(wx.EVT_MENU, self.on_search, id=wx.ID_FIND)
def load_system_font(self): def _apply_theme(self):
"""Load appropriate system font with high DPI support""" """Apply theme colors if available."""
try: if self.theme:
# Get system DPI scale factor self.text_ctrl.SetBackgroundColour(self.theme["content_bg"])
dc = wx.ClientDC(self) self.text_ctrl.SetForegroundColour(self.theme["text"])
dpi_scale = dc.GetPPI().GetWidth() / 96.0 # 96 is standard DPI self.SetBackgroundColour(self.theme["content_bg"])
# Calculate base font size based on DPI def _load_system_font(self):
"""Load appropriate system font with DPI awareness."""
try:
dc = wx.ClientDC(self)
dpi_scale = dc.GetPPI().GetWidth() / 96.0
# Calculate font size based on DPI
base_size = 10 base_size = 10
if dpi_scale > 1.5: if dpi_scale > 1.5:
font_size = int(base_size * 1.5) # 150% scaling font_size = int(base_size * 1.5)
elif dpi_scale > 1.25: elif dpi_scale > 1.25:
font_size = int(base_size * 1.25) # 125% scaling font_size = int(base_size * 1.25)
else: else:
font_size = base_size font_size = base_size
# Try system fonts in order of preference # Try fonts in preference order
font_families = [ font_preferences = [
(wx.FONTFAMILY_TELETYPE, "Consolas"), "Consolas", "Courier New", "Monaco",
(wx.FONTFAMILY_TELETYPE, "Courier New"), "DejaVu Sans Mono", "Liberation Mono"
(wx.FONTFAMILY_TELETYPE, "Monaco"),
(wx.FONTFAMILY_TELETYPE, "DejaVu Sans Mono"),
(wx.FONTFAMILY_TELETYPE, "Liberation Mono"),
] ]
for family, face_name in font_families: for face_name in font_preferences:
font = wx.Font(font_size, family, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False, face_name) font = wx.Font(
font_size, wx.FONTFAMILY_TELETYPE,
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL,
False, face_name
)
if font.IsOk(): if font.IsOk():
logger.info(f"Using font: {face_name} at {font_size}pt") logger.info(f"Using font: {face_name} at {font_size}pt")
return font return font
# Fallback to default monospace # Fallback
font = wx.Font(font_size, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) logger.info("Using system monospace font")
logger.info("Using system monospace font as fallback") return wx.Font(
return font font_size, wx.FONTFAMILY_TELETYPE,
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL
)
except Exception as e: except Exception as e:
logger.error(f"Error loading system font: {e}") logger.error(f"Error loading font: {e}")
# Ultimate fallback return wx.Font(
return wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) 10, wx.FONTFAMILY_TELETYPE,
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL
)
def _get_random_kaomoji(self):
"""Get a random kaomoji for the button label."""
all_kaomojis = [k for group in self.KAOMOJI_GROUPS.values() for k in group]
return random.choice(all_kaomojis) if all_kaomojis else ":)"
def set_target(self, target): def set_target(self, target):
"""Set the current chat target."""
self.target = target self.target = target
def add_message(self, message, username=None, username_color=None, message_color=None, bold=False, italic=False, underline=False): def add_message(self, message, username=None, username_color=None,
"""Thread-safe message addition with username coloring""" message_color=None, bold=False, italic=False, underline=False):
"""Thread-safe message addition."""
try: try:
# Use CallAfter for thread safety
if wx.IsMainThread(): if wx.IsMainThread():
self._add_message_safe(message, username, username_color, message_color, bold, italic, underline) self._add_message_safe(message, username_color, message_color, bold, italic, underline)
else: else:
wx.CallAfter(self._add_message_safe, message, username, username_color, message_color, bold, italic, underline) wx.CallAfter(self._add_message_safe, message, username_color, message_color, bold, italic, underline)
except Exception as e: except Exception as e:
logger.error(f"Error in add_message: {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): def _add_message_safe(self, message, username_color=None, message_color=None,
"""Actually add message - must be called from main thread""" bold=False, italic=False, underline=False):
"""Add message to display with formatting (must be called from main thread)."""
try: try:
# Safety check: ensure text_ctrl still exists
if not self.text_ctrl or not self:
return
self.messages.append(message) self.messages.append(message)
# Save current position for formatting # Check if user is at bottom
start_pos = self.text_ctrl.GetLastPosition() last_pos = self.text_ctrl.GetLastPosition()
current_pos = self.text_ctrl.GetInsertionPoint()
at_bottom = (last_pos - current_pos) <= 10
if username and username_color: # Create text attribute for styling
# Add username with its color text_attr = wx.TextAttr()
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: if message_color:
attr.SetTextColour(message_color) text_attr.SetTextColour(message_color)
else: else:
attr.SetTextColour(wx.Colour(0, 0, 0)) # Black text for white theme text_attr.SetTextColour(self.default_text_colour)
attr.SetFont(self.font) if bold:
self.text_ctrl.SetDefaultStyle(attr) pass # i hate bold
if italic:
text_attr.SetFontStyle(wx.FONTSTYLE_ITALIC)
if underline:
text_attr.SetUnderlined(True)
# Append the message (without username if we already added it) # Set style and append text
if username and username_color: self.text_ctrl.SetDefaultStyle(text_attr)
# Find the message part after username self.text_ctrl.AppendText(message + "\n")
message_text = message[message.find(username) + len(username):]
self.text_ctrl.AppendText(message_text + "\n") # Reset to default style
else: default_attr = wx.TextAttr()
self.text_ctrl.AppendText(message + "\n") default_attr.SetTextColour(self.default_text_colour)
self.text_ctrl.SetDefaultStyle(default_attr)
# Auto-scroll if at bottom
if at_bottom:
self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition())
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
# Auto-scroll to bottom
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
except Exception as e: except Exception as e:
logger.error(f"Error adding message safely: {e}") logger.error(f"Error adding message: {e}")
def add_formatted_message(self, timestamp, username, content, username_color=None, is_action=False): def add_formatted_message(self, timestamp, username, content,
"""Add a formatted message with colored username""" username_color=None, is_action=False):
"""Add a formatted IRC message with colored username."""
try: try:
# Check if user is at bottom
last_pos = self.text_ctrl.GetLastPosition()
current_pos = self.text_ctrl.GetInsertionPoint()
at_bottom = (last_pos - current_pos) <= 10
# Append timestamp with default color
default_attr = wx.TextAttr()
default_attr.SetTextColour(self.default_text_colour)
self.text_ctrl.SetDefaultStyle(default_attr)
self.text_ctrl.AppendText(timestamp)
# Append action marker or opening bracket
if is_action: if is_action:
message = f"{timestamp}* {username} {content}" self.text_ctrl.AppendText("* ")
self.add_message(message, f"* {username}", username_color, wx.Colour(128, 0, 128), italic=True) # Dark purple for actions
else: else:
message = f"{timestamp}<{username}> {content}" self.text_ctrl.AppendText("<")
self.add_message(message, f"<{username}>", username_color, wx.Colour(0, 0, 0)) # Black text
# Append username with color
if username_color:
username_attr = wx.TextAttr()
username_attr.SetTextColour(username_color)
username_attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
self.text_ctrl.SetDefaultStyle(username_attr)
else:
default_attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
self.text_ctrl.SetDefaultStyle(default_attr)
self.text_ctrl.AppendText(username)
# Append closing bracket and content with default color
default_attr.SetFontWeight(wx.FONTWEIGHT_NORMAL)
self.text_ctrl.SetDefaultStyle(default_attr)
if not is_action:
self.text_ctrl.AppendText("> ")
else:
self.text_ctrl.AppendText(" ")
self.text_ctrl.AppendText(content)
self.text_ctrl.AppendText("\n")
# Auto-scroll if at bottom
if at_bottom:
self.text_ctrl.SetInsertionPoint(self.text_ctrl.GetLastPosition())
self.text_ctrl.ShowPosition(self.text_ctrl.GetLastPosition())
except Exception as e: except Exception as e:
logger.error(f"Error adding formatted message: {e}") logger.error(f"Error adding formatted message: {e}")
def add_system_message(self, message, color=None, bold=False): def add_system_message(self, message, color=None, bold=False):
"""Add system message without username coloring""" """Add a system message with optional color and bold formatting."""
try: try:
if color is None: self.add_message(message, message_color=color, bold=bold)
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: except Exception as e:
logger.error(f"Error adding system message: {e}") logger.error(f"Error adding system message: {e}")
def on_text_key_down(self, event): def on_pick_kaomoji(self, event):
"""Handle key events in the text control""" """Show the kaomoji picker popup."""
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: try:
dlg = SearchDialog(self) # Close existing popup if any
dlg.ShowModal() if self.current_popup and not self.current_popup.IsBeingDeleted():
dlg.Destroy() self.current_popup.Dismiss()
except Exception as e:
logger.error(f"Error in search: {e}")
def perform_search(self, search_text, case_sensitive=False, whole_word=False): # Create new popup
"""Perform text search in the chat history""" self.current_popup = KaomojiPicker.KaomojiPicker(
try: self,
self.search_text = search_text self.KAOMOJI_GROUPS,
self.search_positions = [] self.on_kaomoji_insert
self.current_search_index = -1 )
self.current_popup.show_at_button(self.kaomoji_btn)
# 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: except Exception as e:
logger.error(f"Error performing search: {e}") logger.error(f"Error showing kaomoji picker: {e}")
traceback.print_exc() traceback.print_exc()
def highlight_search_result(self): def on_kaomoji_insert(self, kaomoji):
"""Highlight the current search result""" """Insert selected kaomoji into input."""
try: try:
if not self.search_positions or self.current_search_index < 0: current = self.input_ctrl.GetValue()
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() pos = self.input_ctrl.GetInsertionPoint()
text_before = current_text[:pos]
words = text_before.split()
if not words: # Add space before kaomoji if needed
return needs_space = pos > 0 and current[pos - 1] not in (' ', '\t', '\n')
insert_text = f" {kaomoji}" if needs_space else kaomoji
current_word = words[-1] # Insert at cursor
new_value = current[:pos] + insert_text + current[pos:]
self.input_ctrl.SetValue(new_value)
self.input_ctrl.SetInsertionPoint(pos + len(insert_text))
self.input_ctrl.SetFocus()
# 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: except Exception as e:
logger.error(f"Error in tab completion: {e}") logger.error(f"Error inserting kaomoji: {e}")
def on_send(self, event): def on_send(self, event):
"""Send the current message."""
try: try:
self._hide_command_popup() # Hide command popup when sending
message = self.input_ctrl.GetValue().strip() message = self.input_ctrl.GetValue().strip()
if message and self.target: if message and self.target:
self.history.append(message) self.history.append(message)
@@ -371,3 +370,316 @@ class IRCPanel(wx.Panel):
self.input_ctrl.Clear() self.input_ctrl.Clear()
except Exception as e: except Exception as e:
logger.error(f"Error sending message: {e}") logger.error(f"Error sending message: {e}")
def on_input_text_change(self, event):
"""Handle text changes in input field to show command autocomplete."""
try:
current_text = self.input_ctrl.GetValue()
pos = self.input_ctrl.GetInsertionPoint()
# Check if we're typing a command (starts with /)
if current_text.startswith('/') and pos > 0:
# Extract the command part (everything after / up to cursor or space)
text_before_cursor = current_text[:pos]
if ' ' in text_before_cursor:
# Command already has arguments, don't show autocomplete
self._hide_command_popup()
return
# Get the command being typed
command_part = text_before_cursor[1:] # Remove the '/'
self._show_command_popup(command_part)
else:
self._hide_command_popup()
except Exception as e:
logger.error(f"Error in text change handler: {e}")
event.Skip()
def _show_command_popup(self, command_part=""):
"""Show or update command autocomplete popup."""
try:
if not self.command_popup or self.command_popup.IsBeingDeleted():
# Create new popup
self.command_popup = CommandAutocomplete.CommandAutocomplete( # fuckass python
self,
self.IRC_COMMANDS,
self.on_command_select
)
self.command_popup.show_at_input(self.input_ctrl)
# Filter commands
self.command_popup.filter_commands(command_part)
except Exception as e:
logger.error(f"Error showing command popup: {e}")
def _hide_command_popup(self):
"""Hide command autocomplete popup."""
try:
if self.command_popup and not self.command_popup.IsBeingDeleted():
self.command_popup.safe_dismiss()
self.command_popup = None
except Exception as e:
logger.error(f"Error hiding command popup: {e}")
def on_command_select(self, command):
"""Handle command selection from autocomplete."""
try:
current_text = self.input_ctrl.GetValue()
pos = self.input_ctrl.GetInsertionPoint()
# Find the command part (from / to cursor or space)
text_before_cursor = current_text[:pos]
if text_before_cursor.startswith('/'):
# Replace the command part with the selected command
if ' ' in text_before_cursor:
# Has arguments, keep them
space_pos = text_before_cursor.find(' ')
new_text = f"/{command}{current_text[space_pos:]}"
else:
# No arguments, just replace command
new_text = f"/{command} {current_text[pos:]}"
self.input_ctrl.SetValue(new_text)
# Position cursor after command and space
new_pos = len(f"/{command} ")
self.input_ctrl.SetInsertionPoint(new_pos)
self.input_ctrl.SetFocus()
self._hide_command_popup()
except Exception as e:
logger.error(f"Error selecting command: {e}")
def on_key_down(self, event):
"""Handle input field keyboard events."""
try:
keycode = event.GetKeyCode()
# Handle command popup navigation
if self.command_popup and not self.command_popup.IsBeingDeleted() and self.command_popup.IsShown():
if keycode == wx.WXK_UP:
self.command_popup.select_previous()
return # Don't skip, handled
elif keycode == wx.WXK_DOWN:
self.command_popup.select_next()
return # Don't skip, handled
elif keycode == wx.WXK_RETURN or keycode == wx.WXK_NUMPAD_ENTER:
# If popup is shown, select command instead of sending
cmd = self.command_popup.get_selected_command()
if cmd:
self.on_command_select(cmd)
return # Don't skip, handled
elif keycode == wx.WXK_ESCAPE:
self._hide_command_popup()
return # Don't skip, handled
if keycode == wx.WXK_UP:
self._navigate_history_up()
elif keycode == wx.WXK_DOWN:
self._navigate_history_down()
elif keycode == wx.WXK_TAB:
self.handle_tab_completion()
return # Don't skip
elif keycode == wx.WXK_F3:
if event.ShiftDown():
self.find_previous()
else:
self.find_next()
return
else:
event.Skip()
except Exception as e:
logger.error(f"Error in key handler: {e}")
event.Skip()
def _navigate_history_up(self):
"""Navigate up in message history."""
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)])
def _navigate_history_down(self):
"""Navigate down in message history."""
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()
def handle_tab_completion(self):
"""Handle tab completion for commands and nicknames."""
try:
current_text = self.input_ctrl.GetValue()
if not current_text:
return
pos = self.input_ctrl.GetInsertionPoint()
text_before = current_text[:pos]
# Check if we're completing a command (starts with / and no space yet)
if text_before.startswith('/') and ' ' not in text_before:
command_part = text_before[1:] # Remove the '/'
matches = [cmd for cmd, _ in self.IRC_COMMANDS if cmd.lower().startswith(command_part.lower())]
if len(matches) == 1:
# Single match - complete it
new_text = f"/{matches[0]} {current_text[pos:]}"
self.input_ctrl.SetValue(new_text)
self.input_ctrl.SetInsertionPoint(len(f"/{matches[0]} "))
elif len(matches) > 1:
# Multiple matches - show autocomplete popup or status
if not self.command_popup or self.command_popup.IsBeingDeleted():
self._show_command_popup(command_part)
else:
# Update existing popup
self.command_popup.filter_commands(command_part)
display = ', '.join(matches[:5])
if len(matches) > 5:
display += '...'
self.main_frame.SetStatusText(f"Tab completion: {display}")
return
# Otherwise, try nickname completion (only in channels)
if not self.target or not self.target.startswith('#'):
return
users = self.main_frame.channel_users.get(self.target, [])
if not users:
return
words = text_before.split()
if not words:
return
current_word = words[-1]
matches = [u for u in users if u.lower().startswith(current_word.lower())]
if len(matches) == 1:
# Single match - complete it
new_word = matches[0]
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))
elif len(matches) > 1:
# Multiple matches - show options
display = ', '.join(matches[:5])
if len(matches) > 5:
display += '...'
self.main_frame.SetStatusText(f"Tab completion: {display}")
except Exception as e:
logger.error(f"Error in tab completion: {e}")
# Search functionality
def on_text_key_down(self, event):
"""Handle text control keyboard events."""
if event.ControlDown() and event.GetKeyCode() == 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 opening search: {e}")
def perform_search(self, search_text, case_sensitive=False, whole_word=False):
"""Perform text search in chat history."""
try:
self.search_text = search_text
self.search_positions = []
self.current_search_index = -1
full_text = self.text_ctrl.GetValue()
if not full_text or not search_text:
return
# Prepare for case-insensitive search
search_lower = search_text.lower() if not case_sensitive else search_text
text_lower = full_text.lower() if not case_sensitive else full_text
# Find all occurrences
pos = 0
while pos < len(full_text):
found_pos = text_lower.find(search_lower, pos)
if found_pos == -1:
break
# Check whole word constraint
if whole_word:
is_start = found_pos == 0 or not full_text[found_pos - 1].isalnum()
is_end = (found_pos + len(search_text) >= len(full_text) or
not full_text[found_pos + len(search_text)].isalnum())
if is_start and is_end:
self.search_positions.append(found_pos)
pos = found_pos + 1
else:
self.search_positions.append(found_pos)
pos = found_pos + len(search_text)
# Show results
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}")
def highlight_search_result(self):
"""Highlight current search result."""
try:
if not self.search_positions or self.current_search_index < 0:
return
pos = self.search_positions[self.current_search_index]
self.text_ctrl.SetSelection(pos, pos + len(self.search_text))
self.text_ctrl.ShowPosition(pos)
self.main_frame.SetStatusText(
f"Found {self.current_search_index + 1} of "
f"{len(self.search_positions)}: '{self.search_text}'"
)
except Exception as e:
logger.error(f"Error highlighting search: {e}")
def find_next(self):
"""Find next search result."""
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 search result."""
if self.search_positions:
self.current_search_index = (
(self.current_search_index - 1) % len(self.search_positions)
)
self.highlight_search_result()
def insert_text_at_caret(self, text):
"""Insert text at current caret position."""
try:
current = self.input_ctrl.GetValue()
pos = self.input_ctrl.GetInsertionPoint()
new_value = current[:pos] + text + current[pos:]
self.input_ctrl.SetValue(new_value)
self.input_ctrl.SetInsertionPoint(pos + len(text))
except Exception as e:
logger.error(f"Error inserting text: {e}")

View File

@@ -0,0 +1,103 @@
import wx
import socket
import logging
logger = logging.getLogger(__name__)
class InterfaceSelectDialog(wx.Dialog):
"""Dialog that lets the user pick which local interface the server should bind to."""
def __init__(self, parent, current_host="127.0.0.1"):
super().__init__(parent, title="Select Network Interface", size=(420, 380))
try:
self.SetIcon(parent.GetIcon())
except Exception:
pass
self.selected_host = current_host
panel = wx.Panel(self)
panel.SetBackgroundColour(parent.theme["window_bg"])
main_sizer = wx.BoxSizer(wx.VERTICAL)
info = wx.StaticText(
panel,
label="Choose the Network interface where your server should run:\n"
"You are EXPOSING a Server to your LOCAL Network, this may give away who you are!\n",
)
info.Wrap(380)
main_sizer.Add(info, 0, wx.ALL | wx.EXPAND, 10)
self.interface_list = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
self.interface_list.InsertColumn(0, "Interface", width=180)
self.interface_list.InsertColumn(1, "Address", width=180)
self.interfaces = self._gather_interfaces()
current_index = 0
for idx, entry in enumerate[tuple[str, str]](self.interfaces):
name, address = entry
self.interface_list.InsertItem(idx, name)
self.interface_list.SetItem(idx, 1, address)
if address == current_host:
current_index = idx
self.interface_list.Select(current_index)
self.interface_list.EnsureVisible(current_index)
if self.interfaces:
self.selected_host = self.interfaces[current_index][1]
self.interface_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_select)
self.interface_list.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_activate)
main_sizer.Add(self.interface_list, 1, wx.ALL | wx.EXPAND, 10)
button_bar = wx.StdDialogButtonSizer()
ok_btn = wx.Button(panel, wx.ID_OK)
cancel_btn = wx.Button(panel, wx.ID_CANCEL)
button_bar.AddButton(ok_btn)
button_bar.AddButton(cancel_btn)
button_bar.Realize()
main_sizer.Add(button_bar, 0, wx.ALL | wx.EXPAND, 10)
ok_btn.Bind(wx.EVT_BUTTON, self.on_ok)
panel.SetSizer(main_sizer)
def on_select(self, event):
index = event.GetIndex()
_, address = self.interfaces[index]
self.selected_host = address
def _gather_interfaces(self):
entries = [
("Loopback only", "127.0.0.1"),
("All interfaces", "0.0.0.0"),
]
try:
import psutil
seen = {addr for _, addr in entries}
for name, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family == socket.AF_INET and addr.address not in seen:
label = f"{name}"
entries.append((label, addr.address))
seen.add(addr.address)
except Exception as e:
logger.warning(f"Unable to enumerate network interfaces: {e}")
return entries
def get_selected_host(self):
return self.selected_host
def on_activate(self, event):
self.on_select(event)
self.EndModal(wx.ID_OK)
def on_ok(self, event):
index = self.interface_list.GetFirstSelected()
if index == -1 and self.interfaces:
wx.MessageBox("Select an interface before starting the server.", "No Interface Selected", wx.OK | wx.ICON_INFORMATION)
return
if index != -1:
_, address = self.interfaces[index]
self.selected_host = address
event.Skip()

197
src/KaomojiPicker.py Normal file
View File

@@ -0,0 +1,197 @@
import logging
import traceback
import wx
import wx.adv
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class KaomojiPicker(wx.PopupTransientWindow):
def __init__(self, parent, kaomoji_groups, on_select_callback):
super().__init__(parent, wx.BORDER_SIMPLE)
self.on_select_callback = on_select_callback
self.kaomoji_groups = kaomoji_groups
self._init_ui()
self.Bind(wx.EVT_WINDOW_DESTROY, self.on_destroy)
def _init_ui(self):
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Scrolled content area
self.scroll = wx.ScrolledWindow(panel, size=(380, 420), style=wx.VSCROLL)
self.scroll.SetScrollRate(0, 15)
self.scroll_sizer = wx.BoxSizer(wx.VERTICAL)
# Storage for filtering
self.all_buttons = []
self.group_headers = {}
self.group_containers = {}
# Build kaomoji groups
self._build_kaomoji_groups()
self.scroll.SetSizer(self.scroll_sizer)
main_sizer.Add(self.scroll, 1, wx.EXPAND)
panel.SetSizer(main_sizer)
main_sizer.Fit(panel)
self.SetClientSize(panel.GetBestSize())
# Bind events
self._bind_events(panel)
def _build_kaomoji_groups(self):
dc = wx.ClientDC(self.scroll)
dc.SetFont(self.scroll.GetFont())
self.scroll_sizer.AddSpacer(8)
for group_name, kaomojis in self.kaomoji_groups.items():
if not kaomojis:
continue
# Group header
header = self._create_group_header(group_name)
self.group_headers[group_name] = header
self.scroll_sizer.Add(header, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 12)
# Wrap sizer for buttons
wrap_sizer = wx.WrapSizer(wx.HORIZONTAL)
for kaomoji in kaomojis:
btn = self._create_kaomoji_button(kaomoji, dc, group_name, wrap_sizer)
self.all_buttons.append(btn)
wrap_sizer.Add(btn, 0, wx.ALL, 3)
self.group_containers[group_name] = wrap_sizer
self.scroll_sizer.Add(wrap_sizer, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 12)
def _create_group_header(self, group_name):
header = wx.StaticText(self.scroll, label=group_name)
font = header.GetFont()
font.SetWeight(wx.FONTWEIGHT_BOLD)
font.SetPointSize(9)
header.SetFont(font)
return header
def _create_kaomoji_button(self, kaomoji, dc, group_name, wrap_sizer):
text_width, text_height = dc.GetTextExtent(kaomoji)
btn_width = min(max(text_width + 20, 55), 140)
btn_height = text_height + 14
btn = wx.Button(self.scroll, label=kaomoji, size=(btn_width, btn_height))
btn.SetToolTip(f"{kaomoji} - {group_name}")
btn._kaomoji_text = kaomoji.lower()
btn._kaomoji_group = group_name.lower()
btn._group_name = group_name
# Bind click event
btn.Bind(wx.EVT_BUTTON, lambda evt, k=kaomoji: self.on_kaomoji_selected(k))
return btn
def _bind_events(self, panel):
panel.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook)
self.Bind(wx.EVT_KILL_FOCUS, self.on_kill_focus)
def on_kaomoji_selected(self, kaomoji):
try:
if self.on_select_callback:
self.on_select_callback(kaomoji)
except Exception as e:
logger.error(f"Error in kaomoji selection callback: {e}")
finally:
# Safely dismiss the popup
wx.CallAfter(self.safe_dismiss)
def on_search(self, event):
try:
search_text = self.search_ctrl.GetValue().lower().strip()
if not search_text:
self._show_all()
return
visible_groups = set()
for btn in self.all_buttons:
# Match kaomoji text or group name
is_match = (search_text in btn._kaomoji_text or
search_text in btn._kaomoji_group)
btn.Show(is_match)
if is_match:
visible_groups.add(btn._group_name)
# Show/hide group headers
for group_name, header in self.group_headers.items():
header.Show(group_name in visible_groups)
self.scroll.Layout()
self.scroll.FitInside()
except Exception as e:
logger.error(f"Error in kaomoji search: {e}")
def on_clear_search(self, event):
self.search_ctrl.SetValue("")
self._show_all()
def _show_all(self):
for btn in self.all_buttons:
btn.Show()
for header in self.group_headers.values():
header.Show()
self.scroll.Layout()
self.scroll.FitInside()
def on_char_hook(self, event):
if event.GetKeyCode() == wx.WXK_ESCAPE:
self.safe_dismiss()
else:
event.Skip()
def on_kill_focus(self, event):
focused = wx.Window.FindFocus()
if focused and (focused == self.search_ctrl or
focused.GetParent() == self.scroll or
self.scroll.IsDescendant(focused)):
event.Skip()
return
wx.CallLater(100, self.safe_dismiss)
event.Skip()
def on_destroy(self, event):
self.all_buttons.clear()
self.group_headers.clear()
self.group_containers.clear()
event.Skip()
def safe_dismiss(self):
if not self.IsBeingDeleted() and self.IsShown():
try:
self.Dismiss()
except Exception as e:
logger.error(f"Error dismissing popup: {e}")
def show_at_button(self, button):
btn_screen_pos = button.ClientToScreen((0, 0))
popup_size = self.GetSize()
display_size = wx.GetDisplaySize()
# Try to show above the button, otherwise below
if btn_screen_pos.y - popup_size.height > 0:
popup_y = btn_screen_pos.y - popup_size.height
else:
popup_y = btn_screen_pos.y + button.GetSize().height
# Keep popup on screen horizontally
popup_x = max(10, min(btn_screen_pos.x, display_size.x - popup_size.width - 10))
self.Position((popup_x, popup_y), (0, 0))
self.Popup()

102
src/LocalServer.py Normal file
View File

@@ -0,0 +1,102 @@
import ipaddress
import logging
from typing import Callable, Iterable, List, Optional
from irc import server as irc_server
logger = logging.getLogger(__name__)
class LocalOnlyIRCServer(irc_server.IRCServer):
"""IRC server that only accepts connections from local/LAN addresses."""
def __init__(
self,
server_address,
request_handler_class,
allowed_networks: Iterable[ipaddress.IPv4Network],
blocked_callback: Optional[Callable[[str], None]] = None,
):
self.allowed_networks = list(allowed_networks)
self.blocked_callback = blocked_callback
super().__init__(server_address, request_handler_class)
def verify_request(self, request, client_address):
try:
ip = ipaddress.ip_address(client_address[0])
for network in self.allowed_networks:
if ip in network:
return super().verify_request(request, client_address)
except ValueError:
logger.warning("Rejected malformed IP address: %s", client_address[0])
return False
if self.blocked_callback:
self.blocked_callback(client_address[0])
logger.warning("Rejected non-LAN connection from %s", client_address[0])
return False
if __name__ == "__main__":
import argparse
import sys
import signal
def main():
parser = argparse.ArgumentParser(
description="Run a local-only IRC server."
)
parser.add_argument(
"--host", type=str, default="0.0.0.0",
help="Bind host (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=6667,
help="Bind port (default: 6667)"
)
parser.add_argument(
"--channels", type=str, default="#lobby",
help="Comma-separated list of channels (default: #lobby)"
)
parser.add_argument(
"--verbose", action="store_true",
help="Enable verbose logging"
)
args = parser.parse_args()
# Set up logging
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
# Initialize the server manager
manager = LocalServerManager( # pyright: ignore[reportUndefinedVariable]
listen_host=args.host,
listen_port=args.port
)
manager.set_channels([ch.strip() for ch in args.channels.split(",") if ch.strip()])
# Handle Ctrl+C gracefully
def signal_handler(sig, frame):
print("\nStopping server...")
manager.stop()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
try:
manager.start()
print(f"IRC server running on {args.host}:{args.port}")
# Keep the main thread alive while server runs
while manager.is_running():
import time
time.sleep(1)
except Exception as e:
print(f"Error: {e}")
manager.stop()
sys.exit(1)
main()

179
src/LocalServerManager.py Normal file
View File

@@ -0,0 +1,179 @@
import ipaddress
import socket
import threading
import logging
from typing import Callable, Iterable, List, Optional
from LocalServer import LocalOnlyIRCServer
from irc import server as irc_server
logger = logging.getLogger(__name__)
def _default_log_callback(message: str, color=None, bold: bool = False):
"""Fallback logger when UI callback is not available."""
logger.info(message)
class LocalServerManager:
"""Manages the background IRC server lifecycle."""
DEFAULT_CHANNELS = ["#lobby"]
DEFAULT_ALLOWED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"), # Loopback
ipaddress.ip_network("10.0.0.0/8"), # RFC1918
ipaddress.ip_network("172.16.0.0/12"), # RFC1918
ipaddress.ip_network("192.168.0.0/16"), # RFC1918
ipaddress.ip_network("169.254.0.0/16"), # Link-local
]
def __init__(
self,
log_callback: Callable[[str, Optional[object], bool], None] = _default_log_callback,
listen_host: str = "0.0.0.0",
listen_port: int = 6667,
):
self.log_callback = log_callback or _default_log_callback
self.listen_host = listen_host
self.listen_port = listen_port
self.allowed_networks = list(self.DEFAULT_ALLOWED_NETWORKS)
self._channels = list(self.DEFAULT_CHANNELS)
self._server = None
self._thread = None
self._lock = threading.RLock()
self._running = threading.Event()
self._ready = threading.Event()
self._error: Optional[Exception] = None
# Public API ---------------------------------------------------------
def start(self, timeout: float = 5.0):
"""Start the background IRC server."""
with self._lock:
if self._running.is_set():
raise RuntimeError("Local IRC server is already running.")
self._running.set()
self._ready.clear()
self._error = None
self._thread = threading.Thread(
target=self._serve_forever,
name="Local-IRC-Server",
daemon=True,
)
self._thread.start()
if not self._ready.wait(timeout):
self._running.clear()
raise TimeoutError("Local IRC server failed to start in time.")
if self._error:
raise self._error
def stop(self, timeout: float = 5.0):
"""Stop the IRC server if it is running."""
with self._lock:
if not self._running.is_set():
return
server = self._server
thread = self._thread
if server:
server.shutdown()
server.server_close()
if thread:
thread.join(timeout=timeout)
with self._lock:
self._server = None
self._thread = None
self._running.clear()
self._ready.clear()
def is_running(self) -> bool:
return self._running.is_set()
def set_listen_host(self, host: str):
"""Update the bind address. Local server must be stopped."""
with self._lock:
if self._running.is_set():
raise RuntimeError("Stop the server before changing the interface.")
self.listen_host = host
self._log(f"Local server interface set to {host}.")
def get_channels(self) -> List[str]:
with self._lock:
return list(self._channels)
def set_channels(self, channels: Iterable[str]):
cleaned = self._sanitize_channels(channels)
with self._lock:
self._channels = cleaned or list(self.DEFAULT_CHANNELS)
if self.is_running():
self._log(
"Channel list updated. Restart local server to apply changes.",
bold=True,
)
# Internal helpers ---------------------------------------------------
def _serve_forever(self):
try:
server = LocalOnlyIRCServer(
(self.listen_host, self.listen_port),
irc_server.IRCClient,
self.allowed_networks,
blocked_callback=lambda ip: self._log(
f"Blocked connection attempt from {ip}", bold=False
),
)
server.servername = socket.gethostname() or "wxirc-local"
with self._lock:
self._server = server
seed_channels = self._channels or list(self.DEFAULT_CHANNELS)
for channel in seed_channels:
server.channels.setdefault(channel, irc_server.IRCChannel(channel))
self._log(
f"Local IRC server listening on {self.listen_host}:{self.listen_port}",
bold=True,
)
self._ready.set()
server.serve_forever()
except Exception as exc:
logger.exception("Local IRC server failed: %s", exc)
self._error = exc
self._ready.set()
self._log(f"Local server error: {exc}", bold=True)
finally:
self._running.clear()
self._log("Local IRC server stopped.")
def _sanitize_channels(self, channels: Iterable[str]) -> List[str]:
unique = []
seen = set()
for channel in channels:
if not channel:
continue
name = channel.strip()
if not name.startswith("#"):
name = f"#{name}"
if not self._is_valid_channel(name):
continue
if name.lower() not in seen:
unique.append(name)
seen.add(name.lower())
return unique
@staticmethod
def _is_valid_channel(name: str) -> bool:
if len(name) < 2:
return False
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#"
return all(ch in allowed for ch in name)
def _log(self, message: str, color=None, bold: bool = False):
try:
self.log_callback(message, color, bold)
except Exception:
logger.info(message)

104
src/ManageChannelsDialog.py Normal file
View File

@@ -0,0 +1,104 @@
import wx
import wx.adv
class ManageChannelsDialog(wx.Dialog):
"""Simple dialog for curating the local server channel allowlist."""
def __init__(self, parent, channels):
super().__init__(parent, title="Manage Local Channels", size=(360, 420))
try:
self.SetIcon(parent.GetIcon())
except Exception:
pass
panel = wx.Panel(self)
panel.SetBackgroundColour(parent.theme["window_bg"])
main_sizer = wx.BoxSizer(wx.VERTICAL)
info = wx.StaticText(
panel,
label="Channels are shared with anyone on your LAN who joins the built-in server.",
)
info.Wrap(320)
main_sizer.Add(info, 0, wx.ALL | wx.EXPAND, 8)
self.list_box = wx.ListBox(panel)
for channel in channels:
self.list_box.Append(channel)
main_sizer.Add(self.list_box, 1, wx.ALL | wx.EXPAND, 8)
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.channel_input = wx.TextCtrl(panel, style=wx.TE_PROCESS_ENTER)
self.channel_input.SetHint("#channel-name")
add_btn = wx.Button(panel, label="Add")
input_sizer.Add(self.channel_input, 1, wx.RIGHT, 4)
input_sizer.Add(add_btn, 0)
main_sizer.Add(input_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)
remove_btn = wx.Button(panel, label="Remove Selected")
reset_btn = wx.Button(panel, label="Reset to #lobby")
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
btn_sizer.Add(remove_btn, 1, wx.RIGHT, 4)
btn_sizer.Add(reset_btn, 1)
main_sizer.Add(btn_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 8)
button_bar = wx.StdDialogButtonSizer()
ok_btn = wx.Button(panel, wx.ID_OK)
cancel_btn = wx.Button(panel, wx.ID_CANCEL)
button_bar.AddButton(ok_btn)
button_bar.AddButton(cancel_btn)
button_bar.Realize()
main_sizer.Add(button_bar, 0, wx.ALL | wx.EXPAND, 8)
panel.SetSizer(main_sizer)
# Bindings
add_btn.Bind(wx.EVT_BUTTON, self.on_add)
remove_btn.Bind(wx.EVT_BUTTON, self.on_remove)
reset_btn.Bind(wx.EVT_BUTTON, self.on_reset)
self.channel_input.Bind(wx.EVT_TEXT_ENTER, self.on_add)
ok_btn.Bind(wx.EVT_BUTTON, self.on_ok)
def get_channels(self):
return [self.list_box.GetString(i) for i in range(self.list_box.GetCount())]
def on_add(self, event):
value = self.channel_input.GetValue().strip()
if not value:
return
if not value.startswith('#'):
value = f"#{value}"
if not self._is_valid_channel(value):
wx.MessageBox(
"Channel names may contain letters, numbers, -, and _ only.",
"Invalid Channel",
wx.OK | wx.ICON_WARNING,
)
return
if self.list_box.FindString(value) != wx.NOT_FOUND:
wx.MessageBox("Channel already exists.", "Duplicate Channel", wx.OK | wx.ICON_INFORMATION)
return
self.list_box.Append(value)
self.channel_input.Clear()
def on_remove(self, event):
selection = self.list_box.GetSelection()
if selection != wx.NOT_FOUND:
self.list_box.Delete(selection)
def on_reset(self, event):
self.list_box.Clear()
self.list_box.Append("#lobby")
def on_ok(self, event):
if self.list_box.GetCount() == 0:
wx.MessageBox("Add at least one channel before saving.", "No Channels", wx.OK | wx.ICON_INFORMATION)
return
event.Skip()
@staticmethod
def _is_valid_channel(name):
if len(name) < 2:
return False
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#"
return all(ch in allowed for ch in name)

View File

@@ -19,6 +19,7 @@ class NotesDialog(wx.Frame):
style=wx.DEFAULT_FRAME_STYLE) style=wx.DEFAULT_FRAME_STYLE)
self.parent = parent self.parent = parent
self.theme = getattr(parent, "theme", None)
self.notes_data = notes_data or defaultdict(dict) self.notes_data = notes_data or defaultdict(dict)
self.current_note_key = None self.current_note_key = None
self.updating_title = False self.updating_title = False
@@ -27,7 +28,7 @@ class NotesDialog(wx.Frame):
self.last_save_time = time.time() self.last_save_time = time.time()
self.auto_save_interval = 2 # seconds - reduced for immediate saving self.auto_save_interval = 2 # seconds - reduced for immediate saving
self.SetBackgroundColour(wx.Colour(245, 245, 245)) self.SetBackgroundColour(self.get_theme_colour("window_bg", wx.SystemSettings().GetColour(wx.SYS_COLOUR_WINDOW)))
# Set icon if parent has one # Set icon if parent has one
if parent: if parent:
@@ -61,6 +62,11 @@ class NotesDialog(wx.Frame):
if self.GetParent().GetId() == pId: if self.GetParent().GetId() == pId:
self.GetParent().Close() self.GetParent().Close()
def get_theme_colour(self, key, fallback):
if self.theme and key in self.theme:
return self.theme[key]
return fallback
def create_controls(self): def create_controls(self):
# Create menu bar # Create menu bar
self.create_menu_bar() self.create_menu_bar()
@@ -72,6 +78,7 @@ class NotesDialog(wx.Frame):
# Left panel - notes list # Left panel - notes list
left_panel = wx.Panel(splitter) left_panel = wx.Panel(splitter)
left_panel.SetBackgroundColour(self.get_theme_colour("sidebar_bg", left_panel.GetBackgroundColour()))
left_sizer = wx.BoxSizer(wx.VERTICAL) left_sizer = wx.BoxSizer(wx.VERTICAL)
notes_label = wx.StaticText(left_panel, label="Your Notes:") notes_label = wx.StaticText(left_panel, label="Your Notes:")
@@ -101,6 +108,7 @@ class NotesDialog(wx.Frame):
# Right panel - editor # Right panel - editor
right_panel = wx.Panel(splitter) right_panel = wx.Panel(splitter)
right_panel.SetBackgroundColour(self.get_theme_colour("content_bg", right_panel.GetBackgroundColour()))
right_sizer = wx.BoxSizer(wx.VERTICAL) right_sizer = wx.BoxSizer(wx.VERTICAL)
title_label = wx.StaticText(right_panel, label="Note Title:") title_label = wx.StaticText(right_panel, label="Note Title:")
@@ -226,6 +234,7 @@ class NotesDialog(wx.Frame):
def setup_editor_toolbar(self, parent): def setup_editor_toolbar(self, parent):
self.toolbar = wx.Panel(parent) self.toolbar = wx.Panel(parent)
self.toolbar.SetBackgroundColour(self.get_theme_colour("control_bg", self.toolbar.GetBackgroundColour()))
toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL) toolbar_sizer = wx.BoxSizer(wx.HORIZONTAL)
# Text formatting buttons # Text formatting buttons

120
src/ScanHandler.py Normal file
View File

@@ -0,0 +1,120 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
import socket
import threading
import psutil
import ipaddress
import logging
logger = logging.getLogger(__name__)
class ScanHandler:
"""Fast local network IRC scanner with minimal network overhead."""
def __init__(self, timeout=0.35, max_workers=64):
self.timeout = timeout
self.max_workers = max_workers
self._stop_event = threading.Event()
self._thread = None
self.results = []
self.total_hosts = 0
def detect_networks(self):
"""Return private IPv4 networks discovered on local interfaces."""
networks = []
try:
for iface, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family != socket.AF_INET or not addr.netmask:
continue
try:
interface = ipaddress.IPv4Interface(f"{addr.address}/{addr.netmask}")
except ValueError:
continue
if not interface.network.is_private:
continue
network = self._cap_network(interface)
label = f"{iface} : {interface.ip} through {network.with_prefixlen} range"
networks.append({"label": label, "cidr": str(network)})
except Exception as exc:
logger.error("Failed to enumerate interfaces: %s", exc)
if not networks:
default_net = "192.168.1.0/24"
label = f"Default guess : {default_net}"
networks.append({"label": label, "cidr": default_net})
return networks
def start_scan(self, network_cidr, ports, progress_cb=None, result_cb=None, done_cb=None):
"""Launch the threaded scan."""
if self._thread and self._thread.is_alive():
return False
try:
network = ipaddress.ip_network(network_cidr, strict=False)
except ValueError:
return False
hosts = [str(host) for host in network.hosts()]
if not hosts:
hosts = [str(network.network_address)]
self.total_hosts = len(hosts)
self.results.clear()
self._stop_event.clear()
def _worker():
logger.info("Starting IRC scan across %s hosts (%s)", len(hosts), network_cidr)
scanned = 0
try:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = {executor.submit(self._probe_host, host, ports): host for host in hosts}
for future in as_completed(futures):
if self._stop_event.is_set():
break
scanned += 1
server_info = future.result()
if server_info:
self.results.append(server_info)
if result_cb:
result_cb(server_info)
if progress_cb:
progress_cb(scanned, self.total_hosts)
except Exception as exc:
logger.error("Scan failure: %s", exc)
finally:
if done_cb:
done_cb(self.results)
logger.info("IRC scan finished (%s discovered)", len(self.results))
self._thread = threading.Thread(target=_worker, name="IRC-Scan", daemon=True)
self._thread.start()
return True
def stop_scan(self):
self._stop_event.set()
def _probe_host(self, host, ports):
if self._stop_event.is_set():
return None
for port in ports:
try:
with socket.create_connection((host, port), timeout=self.timeout) as sock:
sock.settimeout(0.2)
banner = ""
try:
chunk = sock.recv(256)
if chunk:
banner = chunk.decode(errors="ignore").strip()
except socket.timeout:
banner = "IRC server (silent banner)"
except OSError:
pass
return {"address": host, "port": port, "banner": banner or "IRC server detected"}
except (socket.timeout, ConnectionRefusedError, OSError):
continue
return None
@staticmethod
def _cap_network(interface):
"""Cap huge networks to /24 to keep scans lightweight."""
if interface.network.prefixlen >= 24:
return interface.network
return ipaddress.ip_network(f"{interface.ip}/24", strict=False)

View File

@@ -1,282 +1,19 @@
import ipaddress
import logging import logging
import socket import ScanHandler
import threading import ScanWizardIntroPage
from concurrent.futures import ThreadPoolExecutor, as_completed import ScanWizardResultsPage
import psutil
import wx import wx
import wx.adv as adv import wx.adv as adv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ScanHandler:
"""Fast local network IRC scanner with minimal network overhead."""
def __init__(self, timeout=0.35, max_workers=64):
self.timeout = timeout
self.max_workers = max_workers
self._stop_event = threading.Event()
self._thread = None
self.results = []
self.total_hosts = 0
def detect_networks(self):
"""Return private IPv4 networks discovered on local interfaces."""
networks = []
try:
for iface, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.family != socket.AF_INET or not addr.netmask:
continue
try:
interface = ipaddress.IPv4Interface(f"{addr.address}/{addr.netmask}")
except ValueError:
continue
if not interface.network.is_private:
continue
network = self._cap_network(interface)
label = f"{iface} : {interface.ip} through {network.with_prefixlen} range"
networks.append({"label": label, "cidr": str(network)})
except Exception as exc:
logger.error("Failed to enumerate interfaces: %s", exc)
if not networks:
default_net = "192.168.1.0/24"
label = f"Default guess : {default_net}"
networks.append({"label": label, "cidr": default_net})
return networks
def start_scan(self, network_cidr, ports, progress_cb=None, result_cb=None, done_cb=None):
"""Launch the threaded scan."""
if self._thread and self._thread.is_alive():
return False
try:
network = ipaddress.ip_network(network_cidr, strict=False)
except ValueError:
return False
hosts = [str(host) for host in network.hosts()]
if not hosts:
hosts = [str(network.network_address)]
self.total_hosts = len(hosts)
self.results.clear()
self._stop_event.clear()
def _worker():
logger.info("Starting IRC scan across %s hosts (%s)", len(hosts), network_cidr)
scanned = 0
try:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures = {executor.submit(self._probe_host, host, ports): host for host in hosts}
for future in as_completed(futures):
if self._stop_event.is_set():
break
scanned += 1
server_info = future.result()
if server_info:
self.results.append(server_info)
if result_cb:
result_cb(server_info)
if progress_cb:
progress_cb(scanned, self.total_hosts)
except Exception as exc:
logger.error("Scan failure: %s", exc)
finally:
if done_cb:
done_cb(self.results)
logger.info("IRC scan finished (%s discovered)", len(self.results))
self._thread = threading.Thread(target=_worker, name="IRC-Scan", daemon=True)
self._thread.start()
return True
def stop_scan(self):
self._stop_event.set()
def _probe_host(self, host, ports):
if self._stop_event.is_set():
return None
for port in ports:
try:
with socket.create_connection((host, port), timeout=self.timeout) as sock:
sock.settimeout(0.2)
banner = ""
try:
chunk = sock.recv(256)
if chunk:
banner = chunk.decode(errors="ignore").strip()
except socket.timeout:
banner = "IRC server (silent banner)"
except OSError:
pass
return {"address": host, "port": port, "banner": banner or "IRC server detected"}
except (socket.timeout, ConnectionRefusedError, OSError):
continue
return None
@staticmethod
def _cap_network(interface):
"""Cap huge networks to /24 to keep scans lightweight."""
if interface.network.prefixlen >= 24:
return interface.network
return ipaddress.ip_network(f"{interface.ip}/24", strict=False)
class ScanWizardIntroPage(adv.WizardPageSimple):
def __init__(self, parent, scan_handler):
super().__init__(parent)
self.scan_handler = scan_handler
self.networks = self.scan_handler.detect_networks()
self._build_ui()
def _build_ui(self):
sizer = wx.BoxSizer(wx.VERTICAL)
intro = wx.StaticText(self, label="Scan the local network for open IRC servers.\n\n Security Warning: This scan may reveal information about your device, and may make you vulnerable to attacks.")
intro.Wrap(420)
sizer.Add(intro, 0, wx.ALL, 5)
sizer.Add(wx.StaticText(self, label="Network"), 0, wx.TOP | wx.LEFT, 8)
labels = [net["label"] for net in self.networks]
self.network_choice = wx.Choice(self, choices=labels)
if labels:
self.network_choice.SetSelection(0)
sizer.Add(self.network_choice, 0, wx.EXPAND | wx.ALL, 5)
sizer.Add(wx.StaticText(self, label="Ports (comma separated)"), 0, wx.TOP | wx.LEFT, 8)
self.port_ctrl = wx.TextCtrl(self, value="6667,6697")
sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 5)
self.SetSizer(sizer)
def get_scan_params(self):
selection = self.network_choice.GetSelection()
if selection == wx.NOT_FOUND:
wx.MessageBox("Select a network to scan.", "Missing selection", wx.OK | wx.ICON_WARNING)
return None
cidr = self.networks[selection]["cidr"]
raw_ports = self.port_ctrl.GetValue().split(",")
ports = []
for raw in raw_ports:
raw = raw.strip()
if not raw:
continue
try:
port = int(raw)
if 1 <= port <= 65535:
ports.append(port)
except ValueError:
continue
if not ports:
wx.MessageBox("Enter at least one valid TCP port.", "Invalid ports", wx.OK | wx.ICON_WARNING)
return None
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError:
wx.MessageBox("Invalid network selection.", "Network error", wx.OK | wx.ICON_ERROR)
return None
host_count = max(network.num_addresses - (2 if network.version == 4 and network.prefixlen <= 30 else 0), 1)
return {"cidr": str(network), "ports": ports, "host_count": host_count}
class ScanWizardResultsPage(adv.WizardPageSimple):
def __init__(self, parent, scan_handler, main_frame):
super().__init__(parent)
self.scan_handler = scan_handler
self.main_frame = main_frame
self.discovered = []
self._build_ui()
def _build_ui(self):
sizer = wx.BoxSizer(wx.VERTICAL)
self.summary = wx.StaticText(self, label="Waiting to start…")
sizer.Add(self.summary, 0, wx.ALL, 5)
self.gauge = wx.Gauge(self, range=100, style=wx.GA_SMOOTH)
sizer.Add(self.gauge, 0, wx.EXPAND | wx.ALL, 5)
self.results_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
self.results_list.InsertColumn(0, "Address", width=140)
self.results_list.InsertColumn(1, "Port", width=60)
self.results_list.InsertColumn(2, "Details", width=260)
self.results_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self._toggle_buttons)
self.results_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self._toggle_buttons)
sizer.Add(self.results_list, 1, wx.EXPAND | wx.ALL, 5)
btn_row = wx.BoxSizer(wx.HORIZONTAL)
self.quick_connect_btn = wx.Button(self, label="Quick Connect")
self.quick_connect_btn.Disable()
self.quick_connect_btn.Bind(wx.EVT_BUTTON, self.on_quick_connect)
btn_row.Add(self.quick_connect_btn, 0, wx.RIGHT, 5)
self.rescan_btn = wx.Button(self, label="Rescan")
self.rescan_btn.Bind(wx.EVT_BUTTON, self.on_rescan)
btn_row.Add(self.rescan_btn, 0)
sizer.Add(btn_row, 0, wx.ALL | wx.ALIGN_RIGHT, 5)
self.SetSizer(sizer)
def prepare_for_scan(self, params, start_callback):
self.results_list.DeleteAllItems()
self.discovered = []
self.summary.SetLabel(f"Scanning {params['cidr']} on ports {', '.join(map(str, params['ports']))}")
self.gauge.SetRange(max(params["host_count"], 1))
self.gauge.SetValue(0)
self.quick_connect_btn.Disable()
start_callback(params)
def on_scan_progress(self, scanned, total):
total = max(total, 1)
self.gauge.SetRange(total)
self.gauge.SetValue(min(scanned, total))
self.summary.SetLabel(f"Scanning… {scanned}/{total} hosts checked")
def on_scan_result(self, server_info):
idx = self.results_list.InsertItem(self.results_list.GetItemCount(), server_info["address"])
self.results_list.SetItem(idx, 1, str(server_info["port"]))
self.results_list.SetItem(idx, 2, server_info.get("banner", "IRC server detected"))
self.discovered.append(server_info)
self.summary.SetLabel(f"Found {len(self.discovered)} {"server" if self.discovered == 1 else "servers"}")
def on_scan_complete(self, results):
if results:
self.summary.SetLabel(f"Scan complete : {len(results)} {"server" if len(results) == 1 else "servers"} ready.")
else:
self.summary.SetLabel("Scan complete : no IRC servers discovered.")
self._toggle_buttons()
def on_quick_connect(self, event):
row = self.results_list.GetFirstSelected()
if row == -1:
return
server = self.results_list.GetItemText(row, 0)
port = int(self.results_list.GetItemText(row, 1))
if self.main_frame.quick_connect(server, port):
self.GetParent().EndModal(wx.ID_OK)
def on_rescan(self, event):
wizard = self.GetParent()
wizard.ShowPage(wizard.intro_page)
def _toggle_buttons(self, event=None):
has_selection = self.results_list.GetFirstSelected() != -1
if has_selection:
self.quick_connect_btn.Enable()
else:
self.quick_connect_btn.Disable()
class ScanWizardDialog(adv.Wizard): class ScanWizardDialog(adv.Wizard):
"""Wizard that drives the ScanHandler workflow."""
def __init__(self, parent): def __init__(self, parent):
super().__init__(parent, title="wxScan") super().__init__(parent, title="wxScan")
self.scan_handler = ScanHandler() self.scan_handler = ScanHandler.ScanHandler()
self.intro_page = ScanWizardIntroPage(self, self.scan_handler) self.intro_page = ScanWizardIntroPage.ScanWizardIntroPage(self, self.scan_handler)
self.results_page = ScanWizardResultsPage(self, self.scan_handler, parent) self.results_page = ScanWizardResultsPage.ScanWizardResultsPage(self, self.scan_handler, parent)
self._chain_pages() self._chain_pages()
self.Bind(adv.EVT_WIZARD_PAGE_CHANGING, self.on_page_changing) self.Bind(adv.EVT_WIZARD_PAGE_CHANGING, self.on_page_changing)
self.Bind(adv.EVT_WIZARD_CANCEL, self.on_cancel) self.Bind(adv.EVT_WIZARD_CANCEL, self.on_cancel)

View File

@@ -0,0 +1,59 @@
import wx
import wx.adv as adv
import ipaddress
class ScanWizardIntroPage(adv.WizardPageSimple):
def __init__(self, parent, scan_handler):
super().__init__(parent)
self.scan_handler = scan_handler
self.networks = self.scan_handler.detect_networks()
self._build_ui()
def _build_ui(self):
sizer = wx.BoxSizer(wx.VERTICAL)
intro = wx.StaticText(self, label="Scan the local network for open IRC servers.\n\n Security Warning: This scan may reveal information about your device, and may make you vulnerable to attacks.")
intro.Wrap(420)
sizer.Add(intro, 0, wx.ALL, 5)
sizer.Add(wx.StaticText(self, label="Network"), 0, wx.TOP | wx.LEFT, 8)
labels = [net["label"] for net in self.networks]
self.network_choice = wx.Choice(self, choices=labels)
if labels:
self.network_choice.SetSelection(0)
sizer.Add(self.network_choice, 0, wx.EXPAND | wx.ALL, 5)
sizer.Add(wx.StaticText(self, label="Ports (comma separated)"), 0, wx.TOP | wx.LEFT, 8)
self.port_ctrl = wx.TextCtrl(self, value="6667,6697")
sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 5)
self.SetSizer(sizer)
def get_scan_params(self):
selection = self.network_choice.GetSelection()
if selection == wx.NOT_FOUND:
wx.MessageBox("Select a network to scan.", "Missing selection", wx.OK | wx.ICON_WARNING)
return None
cidr = self.networks[selection]["cidr"]
raw_ports = self.port_ctrl.GetValue().split(",")
ports = []
for raw in raw_ports:
raw = raw.strip()
if not raw:
continue
try:
port = int(raw)
if 1 <= port <= 65535:
ports.append(port)
except ValueError:
continue
if not ports:
wx.MessageBox("Enter at least one valid TCP port.", "Invalid ports", wx.OK | wx.ICON_WARNING)
return None
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError:
wx.MessageBox("Invalid network selection.", "Network error", wx.OK | wx.ICON_ERROR)
return None
host_count = max(network.num_addresses - (2 if network.version == 4 and network.prefixlen <= 30 else 0), 1)
return {"cidr": str(network), "ports": ports, "host_count": host_count}

View File

@@ -0,0 +1,109 @@
import wx
import wx.adv as adv
import logging
logger = logging.getLogger(__name__)
class ScanWizardResultsPage(adv.WizardPageSimple):
def __init__(self, parent, scan_handler, main_frame):
super().__init__(parent)
self.scan_handler = scan_handler
self.main_frame = main_frame
self.discovered = []
self._build_ui()
def _build_ui(self):
sizer = wx.BoxSizer(wx.VERTICAL)
self.summary = wx.StaticText(self, label="Waiting to start…")
sizer.Add(self.summary, 0, wx.ALL, 5)
self.gauge = wx.Gauge(self, range=100, style=wx.GA_SMOOTH)
sizer.Add(self.gauge, 0, wx.EXPAND | wx.ALL, 5)
self.results_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.BORDER_SUNKEN)
self.results_list.InsertColumn(0, "Address", width=140)
self.results_list.InsertColumn(1, "Port", width=60)
self.results_list.InsertColumn(2, "Details", width=260)
self.results_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self._toggle_buttons)
self.results_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self._toggle_buttons)
sizer.Add(self.results_list, 1, wx.EXPAND | wx.ALL, 5)
btn_row = wx.BoxSizer(wx.HORIZONTAL)
self.quick_connect_btn = wx.Button(self, label="Quick Connect")
self.quick_connect_btn.Disable()
self.quick_connect_btn.Bind(wx.EVT_BUTTON, self.on_quick_connect)
btn_row.Add(self.quick_connect_btn, 0, wx.RIGHT, 5)
self.rescan_btn = wx.Button(self, label="Rescan")
self.rescan_btn.Bind(wx.EVT_BUTTON, self.on_rescan)
btn_row.Add(self.rescan_btn, 0)
sizer.Add(btn_row, 0, wx.ALL | wx.ALIGN_RIGHT, 5)
self.SetSizer(sizer)
def prepare_for_scan(self, params, start_callback):
self.results_list.DeleteAllItems()
self.discovered = []
self.summary.SetLabel(f"Scanning {params['cidr']} on ports {', '.join(map(str, params['ports']))}")
self.gauge.SetRange(max(params["host_count"], 1))
self.gauge.SetValue(0)
self.quick_connect_btn.Disable()
start_callback(params)
def on_scan_progress(self, scanned, total):
try:
total = max(total, 1)
self.gauge.SetRange(total)
self.gauge.SetValue(min(scanned, total))
self.summary.SetLabel(f"Scanning… {scanned}/{total} hosts checked")
except RuntimeError:
# C++ SHIT
logger.debug("Scan progress update after controls destroyed; ignoring")
def on_scan_result(self, server_info):
"""Handle a single discovered server row."""
try:
idx = self.results_list.InsertItem(self.results_list.GetItemCount(), server_info["address"])
self.results_list.SetItem(idx, 1, str(server_info["port"]))
self.results_list.SetItem(idx, 2, server_info.get("banner", "IRC server detected"))
self.discovered.append(server_info)
self.summary.SetLabel(
f"Found {len(self.discovered)} {'server' if len(self.discovered) == 1 else 'servers'}"
)
except RuntimeError:
logger.debug("Scan result update after controls destroyed; ignoring")
def on_scan_complete(self, results):
"""Final scan completion callback."""
try:
if results:
self.summary.SetLabel(
f"Scan complete : {len(results)} "
f"{'server' if len(results) == 1 else 'servers'} ready."
)
else:
self.summary.SetLabel("Scan complete : no IRC servers discovered.")
self._toggle_buttons()
except RuntimeError:
logger.debug("Scan completion update after controls destroyed; ignoring")
def on_quick_connect(self, event):
row = self.results_list.GetFirstSelected()
if row == -1:
return
server = self.results_list.GetItemText(row, 0)
port = int(self.results_list.GetItemText(row, 1))
if self.main_frame.quick_connect(server, port):
self.GetParent().EndModal(wx.ID_OK)
def on_rescan(self, event):
wizard = self.GetParent()
wizard.ShowPage(wizard.intro_page)
def _toggle_buttons(self, event=None):
has_selection = self.results_list.GetFirstSelected() != -1
if has_selection:
self.quick_connect_btn.Enable()
else:
self.quick_connect_btn.Disable()

99
src/Win32API.py Normal file
View File

@@ -0,0 +1,99 @@
import ctypes
from ctypes import wintypes
import time
SW_HIDE = 0
SW_SHOW = 5
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x80000
LWA_ALPHA = 0x2
class Win32API:
def __init__(self, hwnd):
self.hwnd = hwnd
self.hidden = False
self._load_api()
def _load_api(self):
user32 = ctypes.windll.user32
self.ShowWindow = user32.ShowWindow
self.GetWindowLong = user32.GetWindowLongW
self.SetWindowLong = user32.SetWindowLongW
self._RegisterHotKey = user32.RegisterHotKey
self._SetLayeredWindowAttributes = user32.SetLayeredWindowAttributes
self.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
self.GetWindowLong.argtypes = [wintypes.HWND, ctypes.c_int]
self.SetWindowLong.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
self._RegisterHotKey.argtypes = [
wintypes.HWND,
wintypes.INT,
wintypes.UINT,
wintypes.UINT,
]
self._SetLayeredWindowAttributes.argtypes = [
wintypes.HWND,
wintypes.COLORREF,
wintypes.BYTE,
wintypes.DWORD,
]
def remove_from_taskbar(self):
style = self.GetWindowLong(self.hwnd, GWL_EXSTYLE)
new_style = (style & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW
self.SetWindowLong(self.hwnd, GWL_EXSTYLE, new_style)
def hide(self):
self.ShowWindow(self.hwnd, SW_HIDE)
self.hidden = True
def show(self):
self.ShowWindow(self.hwnd, SW_SHOW)
self.hidden = False
def toggle(self, fade=True, duration=500, steps=20):
"""Toggle window visibility with optional fade effect"""
if self.hidden:
if fade:
self.fade_in(duration, steps)
else:
self.show()
else:
if fade:
self.fade_out(duration, steps)
else:
self.hide()
def register_hotkey(self, hotkey_id, key, modifiers):
vk = ord(key.upper())
return self._RegisterHotKey(None, hotkey_id, modifiers, vk)
def _set_layered(self):
style = self.GetWindowLong(self.hwnd, GWL_EXSTYLE)
self.SetWindowLong(self.hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED)
def fade_in(self, duration=50, steps=50):
self.show()
self._set_layered()
for idx in range(steps + 1):
alpha = int(255 * (idx / steps))
self._SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA)
ctypes.windll.kernel32.Sleep(int(duration / steps))
self.hidden = False
def fade_out(self, duration=50, steps=50):
self._set_layered()
for idx in range(steps + 1):
alpha = int(255 * (1 - idx /steps))
self._SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA)
ctypes.windll.kernel32.Sleep(int(duration / steps))
self.hidden = True
self.hide()

97
src/Win32SoundHandler.py Normal file
View File

@@ -0,0 +1,97 @@
import ctypes
import logging
from ctypes import wintypes
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def get_resource_path(relative_path):
import os
import sys
try:
base_path = sys._MEIPASS
except Exception:
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__)))
base_path = project_root
full_path = os.path.normpath(os.path.join(base_path, relative_path))
return full_path
class Win32SoundHandler:
def __init__(self, hwnd):
self.hwnd = hwnd
self.SND_ALIAS = 0x00010000
self.SND_FILENAME = 0x00020000
self.SND_ASYNC = 0x00000001
self._setup_playsoundapi()
def _setup_playsoundapi(self):
winmm = ctypes.windll.winmm
self.PlaySoundW = winmm.PlaySoundW
self.PlaySoundW.argtypes = (
wintypes.LPCWSTR,
wintypes.HMODULE,
wintypes.DWORD
)
self.PlaySoundW.restype = wintypes.BOOL
def play_info_sound(self):
self.PlaySoundW("SystemAsterisk", None, self.SND_ALIAS | self.SND_ASYNC)
def play_error_sound(self):
self.PlaySoundW("SystemHand", None, self.SND_ALIAS | self.SND_ASYNC)
def play_warn_sound(self):
self.PlaySoundW("SystemExclamation", None, self.SND_ALIAS | self.SND_ASYNC)
def play_question_sound(self):
self.PlaySoundW("SystemQuestion", None, self.SND_ALIAS | self.SND_ASYNC)
def play_default_sound(self):
self.PlaySoundW("SystemDefault", None, self.SND_ALIAS | self.SND_ASYNC)
def play_notification_sound(self):
self.PlaySoundW("SystemNotification", None, self.SND_ALIAS | self.SND_ASYNC)
def play_mail_sound(self):
self.PlaySoundW("MailBeep", None, self.SND_ALIAS | self.SND_ASYNC)
def play_exit_sound(self):
self.PlaySoundW("SystemExit", None, self.SND_ALIAS | self.SND_ASYNC)
def play_connect_server_or_channel(self):
try:
sound_path = get_resource_path("sounds/space-pdj.wav")
import os
if os.path.exists(sound_path):
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
else:
logger.warning(f"Sound file not found: {sound_path}")
except Exception as e:
logger.error(f"Error playing popout click sound: {e}")
def play_popout_click(self):
try:
sound_path = get_resource_path("sounds/startup.wav")
import os
if os.path.exists(sound_path):
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
else:
logger.warning(f"Sound file not found: {sound_path}")
except Exception as e:
logger.error(f"Error playing popout click sound: {e}")
def play_msg_recv(self):
try:
sound_path = get_resource_path("sounds/balloon.wav")
import os
if os.path.exists(sound_path):
self.PlaySoundW(sound_path, None, self.SND_FILENAME | self.SND_ASYNC)
else:
logger.warning(f"Sound file not found: {sound_path}")
except Exception as e:
logger.error(f"Error playing message received sound: {e}")

BIN
src/channel.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,4 +1,6 @@
import wx import wx
import wx.aui
import wx.lib.agw.aui as aui
import irc.client import irc.client
import threading import threading
import re import re
@@ -15,8 +17,12 @@ import sys
from PrivacyNoticeDialog import PrivacyNoticeDialog from PrivacyNoticeDialog import PrivacyNoticeDialog
from IRCPanel import IRCPanel from IRCPanel import IRCPanel
from AboutDialog import AboutDialog from AboutDialog import AboutDialog
from InterfaceSelectDialog import InterfaceSelectDialog
from ManageChannelsDialog import ManageChannelsDialog
from NotesDialog import NotesDialog from NotesDialog import NotesDialog
if os.name == "nt": from Win32API import Win32API ; from Win32SoundHandler import Win32SoundHandler
from ScanWizard import ScanWizardDialog from ScanWizard import ScanWizardDialog
from LocalServerManager import LocalServerManager
# Set up logging # Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -43,8 +49,9 @@ class IRCFrame(wx.Frame):
def __init__(self): def __init__(self):
super().__init__(None, title="wxIRC", size=(1200, 700)) super().__init__(None, title="wxIRC", size=(1200, 700))
# Apply white theme # Determine platform theme once
self.apply_white_theme() self.theme = self.build_theme()
self.apply_theme()
# Show privacy notice first # Show privacy notice first
self.show_privacy_notice() self.show_privacy_notice()
@@ -70,6 +77,12 @@ class IRCFrame(wx.Frame):
self.timestamps = True self.timestamps = True
self.notes_data = defaultdict(dict) self.notes_data = defaultdict(dict)
self.server_menu_items = {}
self.local_bind_host = "127.0.0.1"
self.local_server_manager = LocalServerManager(
log_callback=self.log_local_server,
listen_host=self.local_bind_host,
)
# User color mapping - darker colors for white theme # User color mapping - darker colors for white theme
self.user_colors = {} self.user_colors = {}
@@ -91,6 +104,18 @@ class IRCFrame(wx.Frame):
self.motd_lines = [] self.motd_lines = []
self.collecting_motd = False self.collecting_motd = False
self.hwnd = self.GetHandle()
self.ctrl = Win32API(self.hwnd)
# Initialize sound handler for Windows
if os.name == "nt":
self.sound_handler = Win32SoundHandler(self.hwnd)
else:
self.sound_handler = None
# Sound toggle state (enabled by default)
self.sounds_enabled = True
self.setup_irc_handlers() self.setup_irc_handlers()
self.create_menubar() self.create_menubar()
self.setup_ui() self.setup_ui()
@@ -121,23 +146,84 @@ class IRCFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002) self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002)
self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003) self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003)
def apply_white_theme(self): HOTKEY_ID = 1
"""Apply white theme to the application""" MOD_CONTROL = 0x0002
MOD_ALT = 0x0001
# Register directly on the wx.Frame
self.RegisterHotKey(HOTKEY_ID, MOD_CONTROL | MOD_ALT, ord('H'))
self.Bind(wx.EVT_HOTKEY, self.on_hotkey, id=HOTKEY_ID)
def on_hotkey(self, event):
self.ctrl.toggle()
def build_theme(self):
"""Build a small theme descriptor that respects the host platform."""
try: try:
# Set system colors for white theme
self.SetBackgroundColour(wx.Colour(255, 255, 255))
self.SetForegroundColour(wx.Colour(0, 0, 0))
# Set system settings for light theme
sys_settings = wx.SystemSettings() sys_settings = wx.SystemSettings()
system_window = sys_settings.GetColour(wx.SYS_COLOUR_WINDOW)
system_text = sys_settings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)
system_face = sys_settings.GetColour(wx.SYS_COLOUR_BTNFACE)
is_windows = wx.Platform == "__WXMSW__"
if is_windows:
window_bg = wx.Colour(255, 255, 255)
control_bg = wx.Colour(240, 240, 240)
text = wx.Colour(0, 0, 0)
else:
window_bg = system_window
control_bg = system_face
text = system_text
sidebar_delta = 15 if self._is_light_colour(window_bg) else -20
sidebar_bg = self._adjust_colour(control_bg if control_bg.IsOk() else window_bg, sidebar_delta)
return {
"window_bg": window_bg,
"content_bg": window_bg,
"text": text,
"sidebar_bg": sidebar_bg,
"control_bg": control_bg if control_bg.IsOk() else window_bg,
"force_light": is_windows,
}
except Exception as e: except Exception as e:
logger.error(f"Error applying white theme: {e}") logger.error(f"Error building theme: {e}")
# Fallback to a simple light theme
return {
"window_bg": wx.Colour(255, 255, 255),
"content_bg": wx.Colour(255, 255, 255),
"text": wx.Colour(0, 0, 0),
"sidebar_bg": wx.Colour(240, 240, 240),
"control_bg": wx.Colour(240, 240, 240),
"force_light": True,
}
def _is_light_colour(self, colour):
"""Simple luminance check to know if a colour is light."""
luminance = 0.299 * colour.Red() + 0.587 * colour.Green() + 0.114 * colour.Blue()
return luminance >= 128
def _adjust_colour(self, colour, delta):
"""Lighten or darken a colour by delta."""
def clamp(value):
return max(0, min(255, value))
return wx.Colour(
clamp(colour.Red() + delta),
clamp(colour.Green() + delta),
clamp(colour.Blue() + delta),
)
def apply_theme(self):
"""Apply the detected theme."""
try:
self.SetBackgroundColour(self.theme["window_bg"])
self.SetForegroundColour(self.theme["text"])
except Exception as e:
logger.error(f"Error applying theme: {e}")
def show_privacy_notice(self): def show_privacy_notice(self):
"""Show privacy notice dialog at startup""" """Show privacy notice dialog at startup"""
dlg = PrivacyNoticeDialog(self) dlg = PrivacyNoticeDialog(self)
dlg.ShowModal()
dlg.Destroy() dlg.Destroy()
def get_user_color(self, username): def get_user_color(self, username):
@@ -199,12 +285,12 @@ class IRCFrame(wx.Frame):
def setup_ui(self): def setup_ui(self):
"""Setup UI components with white theme""" """Setup UI components with white theme"""
panel = wx.Panel(self) panel = wx.Panel(self)
panel.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background panel.SetBackgroundColour(self.theme["window_bg"])
main_sizer = wx.BoxSizer(wx.HORIZONTAL) main_sizer = wx.BoxSizer(wx.HORIZONTAL)
# Left sidebar - light gray for contrast # Left sidebar - light gray for contrast
left_panel = wx.Panel(panel) left_panel = wx.Panel(panel)
left_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray left_panel.SetBackgroundColour(self.theme["sidebar_bg"])
left_sizer = wx.BoxSizer(wx.VERTICAL) left_sizer = wx.BoxSizer(wx.VERTICAL)
conn_box = wx.StaticBox(left_panel, label="Connection") conn_box = wx.StaticBox(left_panel, label="Connection")
@@ -268,19 +354,35 @@ class IRCFrame(wx.Frame):
left_panel.SetSizer(left_sizer) left_panel.SetSizer(left_sizer)
# Center - Notebook self.notebook = wx.aui.AuiNotebook(panel, style=
self.notebook = wx.Notebook(panel) wx.aui.AUI_NB_DEFAULT_STYLE |
self.notebook.SetBackgroundColour(wx.Colour(255, 255, 255)) wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB |
wx.aui.AUI_NB_MIDDLE_CLICK_CLOSE
)
self.notebook.SetBackgroundColour(self.theme["content_bg"])
# Setup tab icons
self.setup_tab_icons()
# Bind close event
self.notebook.Bind(wx.aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.on_notebook_page_close)
# Server panel
server_panel = IRCPanel(self.notebook, self) server_panel = IRCPanel(self.notebook, self)
server_panel.set_target("SERVER") server_panel.set_target("SERVER")
idx = self.notebook.GetPageCount()
self.notebook.AddPage(server_panel, "Server") self.notebook.AddPage(server_panel, "Server")
# THIS is the missing line
if self.icon_server != -1:
self.notebook.SetPageImage(idx, self.icon_server)
self.channels["SERVER"] = server_panel self.channels["SERVER"] = server_panel
# Right sidebar - Users - light gray for contrast # Right sidebar - Users - light gray for contrast
right_panel = wx.Panel(panel) right_panel = wx.Panel(panel)
right_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray right_panel.SetBackgroundColour(self.theme["sidebar_bg"])
right_sizer = wx.BoxSizer(wx.VERTICAL) right_sizer = wx.BoxSizer(wx.VERTICAL)
users_box = wx.StaticBox(right_panel, label="Users") users_box = wx.StaticBox(right_panel, label="Users")
@@ -335,8 +437,12 @@ class IRCFrame(wx.Frame):
file_menu = wx.Menu() file_menu = wx.Menu()
file_menu.Append(300, "&About", "About wxIRC Client") file_menu.Append(300, "&About", "About wxIRC Client")
file_menu.AppendSeparator() file_menu.AppendSeparator()
self.sound_toggle_item = file_menu.AppendCheckItem(301, "Toggle &Sounds")
self.sound_toggle_item.Check(True) # Sounds enabled by default
file_menu.AppendSeparator()
file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q") file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q")
self.Bind(wx.EVT_MENU, self.on_about, id=300) self.Bind(wx.EVT_MENU, self.on_about, id=300)
self.Bind(wx.EVT_MENU, self.on_toggle_sounds, id=301)
self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT) self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT)
# Edit menu with search # Edit menu with search
@@ -383,15 +489,37 @@ class IRCFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.on_menu_help, id=207) self.Bind(wx.EVT_MENU, self.on_menu_help, id=207)
self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210) self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210)
# IRC Server menu
server_menu = wx.Menu()
start_item = server_menu.Append(401, "Start Local Server")
stop_item = server_menu.Append(402, "Stop Local Server")
stop_item.Enable(False)
server_menu.AppendSeparator()
manage_item = server_menu.Append(403, "Manage Channels...")
self.server_menu_items = {
"start": start_item,
"stop": stop_item,
"manage": manage_item,
}
self.Bind(wx.EVT_MENU, self.on_start_local_server, id=401)
self.Bind(wx.EVT_MENU, self.on_stop_local_server, id=402)
self.Bind(wx.EVT_MENU, self.on_manage_local_channels, id=403)
menubar.Append(file_menu, "&File") menubar.Append(file_menu, "&File")
menubar.Append(edit_menu, "&Edit") menubar.Append(edit_menu, "&Edit")
menubar.Append(channel_menu, "&Channel") menubar.Append(channel_menu, "&Channel")
menubar.Append(tools_menu, "&Tools") menubar.Append(tools_menu, "&Tools")
menubar.Append(server_menu, "&IRC Server")
self.SetMenuBar(menubar) self.SetMenuBar(menubar)
except Exception as e: except Exception as e:
logger.error(f"Error creating menu: {e}") logger.error(f"Error creating menu: {e}")
def on_toggle_sounds(self, event):
"""Toggle sound effects on/off"""
self.sounds_enabled = self.sound_toggle_item.IsChecked()
logger.info(f"Sounds {'enabled' if self.sounds_enabled else 'disabled'}")
def on_about(self, event): def on_about(self, event):
"""Show About dialog""" """Show About dialog"""
try: try:
@@ -400,6 +528,11 @@ class IRCFrame(wx.Frame):
dlg.Destroy() dlg.Destroy()
except Exception as e: except Exception as e:
logger.error(f"Error showing about dialog: {e}") logger.error(f"Error showing about dialog: {e}")
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_error_sound()
except:
pass
def on_notes(self, event): def on_notes(self, event):
"""Open notes editor dialog""" """Open notes editor dialog"""
@@ -420,6 +553,11 @@ class IRCFrame(wx.Frame):
except Exception as e: except Exception as e:
logger.error(f"Error opening notes dialog: {e}") logger.error(f"Error opening notes dialog: {e}")
self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0)) self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0))
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_error_sound()
except:
pass
def on_notes_closed(self, event=None): def on_notes_closed(self, event=None):
"""Handle notes frame closing""" """Handle notes frame closing"""
@@ -586,6 +724,13 @@ class IRCFrame(wx.Frame):
self.SetStatusText("Connection failed") self.SetStatusText("Connection failed")
logger.error(f"Connection failed: {error_msg}") logger.error(f"Connection failed: {error_msg}")
# Play error sound
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_error_sound()
except Exception as e:
logger.error(f"Error playing connection failure sound: {e}")
def disconnect(self): def disconnect(self):
"""Safely disconnect from IRC server""" """Safely disconnect from IRC server"""
try: try:
@@ -615,6 +760,13 @@ class IRCFrame(wx.Frame):
def on_disconnect_cleanup(self): def on_disconnect_cleanup(self):
"""Clean up after disconnect""" """Clean up after disconnect"""
# Play disconnect notification sound
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_warn_sound()
except Exception as e:
logger.error(f"Error playing disconnect sound: {e}")
with self.connection_lock: with self.connection_lock:
self.connection = None self.connection = None
self.is_connecting = False self.is_connecting = False
@@ -782,38 +934,76 @@ Available commands:
user_color = self.get_user_color(self.nick) user_color = self.get_user_color(self.nick)
timestamp = self.get_timestamp() timestamp = self.get_timestamp()
self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True) self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True)
elif cmd == "nick" and self.is_connected(): elif cmd == "nick":
self.connection.nick(args) if self.is_connected():
elif cmd == "join" and self.is_connected(): self.connection.nick(args)
if args and not args.startswith('#'):
args = '#' + args
self.connection.join(args)
elif cmd == "part" and self.is_connected():
channel = args if args else target
self.connection.part(channel)
elif cmd == "quit" and self.is_connected():
reason = args if args else "Goodbye"
self.connection.quit(reason)
elif cmd == "msg" and self.is_connected():
nick_msg = args.split(' ', 1)
if len(nick_msg) == 2:
self.connection.privmsg(nick_msg[0], nick_msg[1])
elif cmd == "whois" and self.is_connected():
self.connection.whois([args])
elif cmd == "kick" and self.is_connected():
kick_args = args.split(' ', 1)
user = kick_args[0]
reason = kick_args[1] if len(kick_args) > 1 else "Kicked"
self.connection.kick(target, user, reason)
elif cmd == "topic" and self.is_connected():
if args:
self.connection.topic(target, args)
else: else:
self.connection.topic(target) self.safe_ui_update(self.log_server, f"Not connected. Cannot change nickname.", wx.Colour(255, 0, 0))
elif cmd == "away" and self.is_connected(): elif cmd == "join":
self.connection.send_raw(f"AWAY :{args}" if args else "AWAY") if self.is_connected():
self.away = bool(args) if not args:
self.safe_ui_update(self.away_item.Check, self.away) self.safe_ui_update(self.log_server, f"Usage: /join <channel>", wx.Colour(255, 0, 0))
else:
if not args.startswith('#'):
args = '#' + args
self.connection.join(args)
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot join channel.", wx.Colour(255, 0, 0))
elif cmd == "part":
if self.is_connected():
channel = args if args else target
self.connection.part(channel)
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot part channel.", wx.Colour(255, 0, 0))
elif cmd == "quit":
if self.is_connected():
reason = args if args else "Goodbye"
self.connection.quit(reason)
else:
self.safe_ui_update(self.log_server, f"Not connected.", wx.Colour(255, 0, 0))
elif cmd == "msg":
if self.is_connected():
nick_msg = args.split(' ', 1)
if len(nick_msg) == 2:
self.connection.privmsg(nick_msg[0], nick_msg[1])
else:
self.safe_ui_update(self.log_server, f"Usage: /msg <nick> <message>", wx.Colour(255, 0, 0))
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot send private message.", wx.Colour(255, 0, 0))
elif cmd == "whois":
if self.is_connected():
if args:
self.connection.whois([args])
else:
self.safe_ui_update(self.log_server, f"Usage: /whois <nick>", wx.Colour(255, 0, 0))
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot perform WHOIS.", wx.Colour(255, 0, 0))
elif cmd == "kick":
if self.is_connected():
if args:
kick_args = args.split(' ', 1)
user = kick_args[0]
reason = kick_args[1] if len(kick_args) > 1 else "Kicked"
self.connection.kick(target, user, reason)
else:
self.safe_ui_update(self.log_server, f"Usage: /kick <user> [reason]", wx.Colour(255, 0, 0))
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot kick user.", wx.Colour(255, 0, 0))
elif cmd == "topic":
if self.is_connected():
if args:
self.connection.topic(target, args)
else:
self.connection.topic(target)
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot get/set topic.", wx.Colour(255, 0, 0))
elif cmd == "away":
if self.is_connected():
self.connection.send_raw(f"AWAY :{args}" if args else "AWAY")
self.away = bool(args)
self.safe_ui_update(self.away_item.Check, self.away)
else:
self.safe_ui_update(self.log_server, f"Not connected. Cannot set away status.", wx.Colour(255, 0, 0))
else: else:
self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0)) self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0))
except Exception as e: except Exception as e:
@@ -831,11 +1021,18 @@ Available commands:
try: try:
if channel in self.channels and channel != "SERVER": if channel in self.channels and channel != "SERVER":
def _close_channel(): def _close_channel():
# Find and delete the page
for i in range(self.notebook.GetPageCount()): for i in range(self.notebook.GetPageCount()):
if self.notebook.GetPageText(i) == channel: if self.notebook.GetPageText(i) == channel:
self.notebook.DeletePage(i) self.notebook.DeletePage(i)
break break
del self.channels[channel]
# Clean up channel data
if channel in self.channels:
del self.channels[channel]
if channel in self.channel_users:
del self.channel_users[channel]
idx = self.channel_list.FindString(channel) idx = self.channel_list.FindString(channel)
if idx != wx.NOT_FOUND: if idx != wx.NOT_FOUND:
@@ -852,11 +1049,24 @@ Available commands:
except Exception as e: except Exception as e:
logger.error(f"Error logging server message: {e}") logger.error(f"Error logging server message: {e}")
def log_local_server(self, message, color=None, bold=False):
"""Bridge LocalServerManager log output into the Server tab."""
display_color = color or wx.Colour(34, 139, 34) # Forest green
self.safe_ui_update(
self.log_server,
f"[Local Server] {message}",
display_color,
bold,
)
def log_channel_message(self, channel, username, message, is_action=False, is_system=False): def log_channel_message(self, channel, username, message, is_action=False, is_system=False):
"""Log a message to a channel with username coloring""" """Log a message to a channel with username coloring"""
try: try:
# Don't create new channels if they don't exist and we're trying to log to them
if channel not in self.channels: if channel not in self.channels:
self.safe_ui_update(self.add_channel, channel) # Only create channel if it's being opened by the user, not just receiving messages
return
if channel in self.channels: if channel in self.channels:
timestamp = self.get_timestamp() timestamp = self.get_timestamp()
@@ -879,12 +1089,140 @@ Available commands:
except Exception as e: except Exception as e:
logger.error(f"Error logging channel message: {e}") logger.error(f"Error logging channel message: {e}")
def setup_tab_icons(self):
try:
self.tab_image_list = wx.ImageList(32, 32)
# Icon file names to search for
icon_files = {
'server': 'server.ico',
'channel': 'channel.ico',
'query': 'channel.ico' # Reuse channel.ico for queries if no query.ico exists
}
# Search paths for icons
base_paths = [
os.path.dirname(__file__),
get_resource_path(""),
os.path.join(os.getcwd(), "src"),
]
# Try to load each icon
loaded_icons = {}
for icon_type, filename in icon_files.items():
icon_path = None
for base_path in base_paths:
test_path = os.path.join(base_path, filename)
if os.path.exists(test_path):
icon_path = test_path
break
if icon_path:
try:
img = wx.Image(icon_path, wx.BITMAP_TYPE_ICO)
img = img.Scale(32, 32, wx.IMAGE_QUALITY_HIGH)
bmp = wx.Bitmap(img)
loaded_icons[icon_type] = self.tab_image_list.Add(bmp)
logger.info(f"Loaded {icon_type} icon from {icon_path}")
except Exception as e:
logger.warning(f"Failed to load {icon_type} icon: {e}")
loaded_icons[icon_type] = -1
else:
logger.info(f"{filename} not found for {icon_type}")
loaded_icons[icon_type] = -1
# Assign icon indices
self.icon_server = loaded_icons.get('server', -1)
self.icon_channel = loaded_icons.get('channel', -1)
self.icon_query = loaded_icons.get('query', -1)
# Use fallback icons if any failed to load
if self.icon_server == -1 or self.icon_channel == -1 or self.icon_query == -1:
logger.info("Using fallback icons for missing icon files")
self._setup_fallback_icons()
self.notebook.SetImageList(self.tab_image_list)
except Exception as e:
logger.error(f"Error setting up tab icons: {e}")
self.icon_server = -1
self.icon_channel = -1
self.icon_query = -1
def _setup_fallback_icons(self):
"""Setup fallback icons using wx.ArtProvider."""
try:
self.icon_server = self.tab_image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_INFORMATION, wx.ART_OTHER, (48, 48))
)
self.icon_channel = self.tab_image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_NORMAL_FILE, wx.ART_OTHER, (48, 48))
)
self.icon_query = self.tab_image_list.Add(
wx.ArtProvider.GetBitmap(wx.ART_HELP, wx.ART_OTHER, (48, 48))
)
except Exception as e:
logger.error(f"Error setting up fallback icons: {e}")
self.icon_server = -1
self.icon_channel = -1
self.icon_query = -1
def on_notebook_page_close(self, event):
"""Handle tab close button clicks"""
try:
page_idx = event.GetSelection()
channel = self.notebook.GetPageText(page_idx)
if channel == "Server":
event.Veto()
wx.MessageBox("Can't close Server!", "Error", wx.OK | wx.ICON_ERROR)
return
if channel in self.channels:
del self.channels[channel]
if channel in self.channel_users:
del self.channel_users[channel]
# Remove from channel list
idx = self.channel_list.FindString(channel)
if idx != wx.NOT_FOUND:
self.channel_list.Delete(idx)
if channel.startswith('#') and self.is_connected():
try:
self.connection.part(channel)
except:
pass
# Allow the close to proceed
event.Skip()
except Exception as e:
logger.error(f"Error closing tab: {e}")
event.Skip()
def add_channel(self, channel): def add_channel(self, channel):
"""Add a new channel/query tab with appropriate icon"""
try: try:
if channel not in self.channels: if channel not in self.channels:
panel = IRCPanel(self.notebook, self) panel = IRCPanel(self.notebook, self)
panel.set_target(channel) panel.set_target(channel)
self.notebook.AddPage(panel, channel)
# Determine icon based on channel type
if channel == "SERVER":
icon_idx = getattr(self, 'icon_server', -1)
elif channel.startswith('#'):
icon_idx = getattr(self, 'icon_channel', -1)
else:
icon_idx = getattr(self, 'icon_query', -1)
# Add page with icon (if icon_idx is -1, no icon will be shown)
if icon_idx >= 0:
self.notebook.AddPage(panel, channel, select=True, imageId=icon_idx)
else:
self.notebook.AddPage(panel, channel, select=True)
self.channels[channel] = panel self.channels[channel] = panel
if channel.startswith('#'): if channel.startswith('#'):
@@ -901,6 +1239,11 @@ Available commands:
except Exception as e: except Exception as e:
logger.error(f"Error opening scan wizard: {e}") logger.error(f"Error opening scan wizard: {e}")
self.log_server(f"Scan wizard failed to open: {e}", wx.Colour(255, 0, 0)) self.log_server(f"Scan wizard failed to open: {e}", wx.Colour(255, 0, 0))
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_error_sound()
except:
pass
def quick_connect(self, server, port): def quick_connect(self, server, port):
"""Populate connection fields and initiate a connection if idle.""" """Populate connection fields and initiate a connection if idle."""
@@ -920,6 +1263,65 @@ Available commands:
return False return False
# Menu handlers # Menu handlers
def update_server_menu_state(self):
try:
if not self.server_menu_items:
return
running = self.local_server_manager.is_running()
self.server_menu_items["start"].Enable(not running)
self.server_menu_items["stop"].Enable(running)
except Exception as e:
logger.error(f"Error updating server menu state: {e}")
def on_start_local_server(self, event):
try:
dlg = InterfaceSelectDialog(self, self.local_bind_host)
result = dlg.ShowModal()
if result != wx.ID_OK:
dlg.Destroy()
return
selected_host = dlg.get_selected_host()
dlg.Destroy()
if selected_host != self.local_bind_host:
self.local_server_manager.set_listen_host(selected_host)
self.local_bind_host = selected_host
self.local_server_manager.start()
self.update_server_menu_state()
self.SetStatusText(f"Local IRC server running on {self.local_bind_host}:6667 (LAN only)")
self.log_local_server(f"Server is online and listening on {self.local_bind_host}:6667.")
except Exception as e:
logger.error(f"Failed to start local server: {e}")
self.log_local_server(f"Failed to start: {e}", wx.Colour(255, 0, 0), bold=True)
wx.MessageBox(f"Could not start local IRC server:\n{e}", "Server Error", wx.OK | wx.ICON_ERROR)
self.update_server_menu_state()
def on_stop_local_server(self, event):
try:
self.local_server_manager.stop()
self.update_server_menu_state()
self.SetStatusText("Local IRC server stopped")
self.log_local_server("Server stopped.")
except Exception as e:
logger.error(f"Failed to stop local server: {e}")
self.log_local_server(f"Failed to stop: {e}", wx.Colour(255, 0, 0), bold=True)
def on_manage_local_channels(self, event):
try:
dlg = ManageChannelsDialog(self, self.local_server_manager.get_channels())
if dlg.ShowModal() == wx.ID_OK:
channels = dlg.get_channels()
self.local_server_manager.set_channels(channels)
self.log_local_server(
f"Manage Channels updated: {', '.join(channels)}",
wx.Colour(0, 100, 0),
)
dlg.Destroy()
except Exception as e:
logger.error(f"Error managing local channels: {e}")
wx.MessageBox(f"Unable to open Manage Channels: {e}", "Error", wx.OK | wx.ICON_ERROR)
def on_menu_join(self, event): def on_menu_join(self, event):
try: try:
dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel") dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel")
@@ -1050,6 +1452,13 @@ COMMANDS (type /help in chat for full list):
self.log_server("Connected to server!", wx.Colour(0, 128, 0), bold=True) # Dark green self.log_server("Connected to server!", wx.Colour(0, 128, 0), bold=True) # Dark green
self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(0, 100, 0)) self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(0, 100, 0))
# Play welcome/connection sound
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_connect_server_or_channel()
except Exception as e:
logger.error(f"Error playing welcome sound: {e}")
# Auto-join channels # Auto-join channels
for channel in self.auto_join_channels: for channel in self.auto_join_channels:
if not channel.startswith('#'): if not channel.startswith('#'):
@@ -1069,6 +1478,13 @@ COMMANDS (type /help in chat for full list):
self.safe_ui_update(self.add_channel, channel) self.safe_ui_update(self.add_channel, channel)
self.log_server(f"Joined channel {channel}", wx.Colour(0, 128, 0)) # Dark green self.log_server(f"Joined channel {channel}", wx.Colour(0, 128, 0)) # Dark green
# Play sound when we join a channel
if self.sound_handler and self.sounds_enabled:
try:
self.sound_handler.play_connect_server_or_channel()
except Exception as e:
logger.error(f"Error playing channel join sound: {e}")
self.log_channel_message(channel, nick, f"{nick} joined", is_system=True) self.log_channel_message(channel, nick, f"{nick} joined", is_system=True)
if nick not in self.channel_users[channel]: if nick not in self.channel_users[channel]:
@@ -1126,6 +1542,13 @@ COMMANDS (type /help in chat for full list):
else: else:
self.log_channel_message(channel, nick, message) self.log_channel_message(channel, nick, message)
# Play sound notification only for real user messages (not from self)
if self.sound_handler and self.sounds_enabled and nick.lower() != self.nick.lower():
try:
self.sound_handler.play_msg_recv()
except Exception as e:
logger.error(f"Error playing message sound: {e}")
# Highlight own nick in messages # Highlight own nick in messages
if self.nick.lower() in message.lower(): if self.nick.lower() in message.lower():
self.safe_ui_update(wx.Bell) self.safe_ui_update(wx.Bell)
@@ -1143,6 +1566,13 @@ COMMANDS (type /help in chat for full list):
self.log_channel_message(nick, nick, message, is_action=True) self.log_channel_message(nick, nick, message, is_action=True)
else: else:
self.log_channel_message(nick, nick, message) self.log_channel_message(nick, nick, message)
# Play mail sound for private messages
if self.sound_handler and self.sounds_enabled and nick.lower() != self.nick.lower():
try:
self.sound_handler.play_mail_sound()
except Exception as e:
logger.error(f"Error playing private message sound: {e}")
except Exception as e: except Exception as e:
logger.error(f"Error in privmsg handler: {e}") logger.error(f"Error in privmsg handler: {e}")
@@ -1317,6 +1747,9 @@ COMMANDS (type /help in chat for full list):
if self.ui_timer and self.ui_timer.IsRunning(): if self.ui_timer and self.ui_timer.IsRunning():
self.ui_timer.Stop() self.ui_timer.Stop()
if self.local_server_manager and self.local_server_manager.is_running():
self.local_server_manager.stop()
# Notes data will be lost when app closes (RAM only) # Notes data will be lost when app closes (RAM only)
# User can save to file if they want persistence # User can save to file if they want persistence
@@ -1341,14 +1774,12 @@ if os.name == 'nt':
ctypes.windll.user32.SetProcessDPIAware() ctypes.windll.user32.SetProcessDPIAware()
except: except:
pass pass
else:
pass
if __name__ == "__main__": if __name__ == "__main__":
try: try:
if os.name == 'nt': if os.name == 'nt':
enable_high_dpi() enable_high_dpi()
app = wx.App() app = wx.App()
frame = IRCFrame() frame = IRCFrame()
frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO)) frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO))

BIN
src/server.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
src/sounds/balloon.wav Normal file

Binary file not shown.

BIN
src/sounds/space-pdj.wav Normal file

Binary file not shown.

BIN
src/sounds/startup.wav Normal file

Binary file not shown.