From 2db457efd235de1f096d327579f19c878fcab550 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Sat, 12 Jul 2025 22:02:09 +0200 Subject: [PATCH] Refactor interaction between TUI and Connection. --- ircplom.py | 8 +-- ircplom/irc_conn.py | 115 +++++++++++++++++++------------------------- ircplom/tui.py | 66 ++++++++++++------------- 3 files changed, 84 insertions(+), 105 deletions(-) diff --git a/ircplom.py b/ircplom.py index aeff1c6..3fcceb6 100755 --- a/ircplom.py +++ b/ircplom.py @@ -8,7 +8,7 @@ from ircplom.tui import Terminal def main_loop() -> None: 'Main execution code / loop.' q_to_main: EventQueue = EventQueue() - connections: list[IrcConnection] = [] + connections: set[IrcConnection] = set() try: with Terminal().context(q_to_main) as term: while True: @@ -19,10 +19,10 @@ def main_loop() -> None: if isinstance(event, ExceptionEvent): raise event.payload if isinstance(event, InitConnectEvent): - connections += [IrcConnection(q_to_main, len(connections), - *event.payload)] + connections.add(IrcConnection(q_to_main=q_to_main, + *event.payload)) elif isinstance(event, ConnEvent): - connections[event.conn_idx].handle(event) + event.conn.handle(event) finally: for conn in connections: conn.close() diff --git a/ircplom/irc_conn.py b/ircplom/irc_conn.py index ae2649b..289ecfd 100644 --- a/ircplom/irc_conn.py +++ b/ircplom/irc_conn.py @@ -5,8 +5,7 @@ from socket import socket, gaierror as socket_gaierror from threading import Thread from typing import Callable, Iterator, NamedTuple, Optional, Self # ourselves -from ircplom.events import (BroadcastMixin, Event, EventQueue, Loop, - PayloadMixin) +from ircplom.events import BroadcastMixin, Event, Loop, PayloadMixin TIMEOUT_LOOP = 0.1 @@ -123,14 +122,6 @@ class _IrcMessage: return self._raw -class _ConnIdxMixin: - 'Collects a Connection ID at .conn_idx.' - - def __init__(self, conn_idx: int, **kwargs) -> None: - super().__init__(**kwargs) - self.conn_idx = conn_idx - - @dataclass class LoginNames: 'Collects the names needed on server connect for USER, NICK commands.' @@ -140,18 +131,21 @@ class LoginNames: nick_confirmed: bool = False +class ConnMixin: + 'Collects an IrcConnection at .conn.' + + def __init__(self, conn: 'IrcConnection', **kwargs) -> None: + super().__init__(**kwargs) + self.conn = conn + + class InitConnectEvent(Event, PayloadMixin): 'Event to trigger connection, with payload (host, LoginNames).' payload: tuple[str, LoginNames] -class ConnEvent(Event, _ConnIdxMixin): - 'Event with .conn_idx.' - - -class InitConnWindowEvent(ConnEvent, PayloadMixin): - 'Event to trigger TUI making ConnectionWindow.' - payload: LoginNames +class ConnEvent(Event, ConnMixin): + 'Event with .conn.' class _ConnectedEvent(ConnEvent): @@ -177,68 +171,62 @@ class NickSetEvent(ConnEvent): class _SendEvent(ConnEvent, PayloadMixin): 'Event to trigger sending of payload to server.' - payload: '_IrcMessage' - - -class BroadcastConnMixin(BroadcastMixin, _ConnIdxMixin): - 'Provides .broadcast_conn on classes that have .conn_idx defined.' + payload: _IrcMessage - def broadcast_conn[E: ConnEvent](self, - event_class: type[E], - *args, **kwargs - ) -> None: - 'Broadcast event subclassing ConnEvent, with connection ID.' - self.broadcast(event_class, conn_idx=self.conn_idx, *args, **kwargs) - def send(self, verb: str, parameters: tuple[str, ...]) -> None: - 'Broadcast _SendEvent for _IrcMessage(verb, parameters).' - self.broadcast_conn(_SendEvent, _IrcMessage(verb, parameters)) - - -class IrcConnection(BroadcastConnMixin): +class IrcConnection(BroadcastMixin): 'Abstracts socket connection, loop over it, and handling messages from it.' def __init__(self, - q_to_main: EventQueue, - idx: int, hostname: str, login: LoginNames, + **kwargs ) -> None: - super().__init__(conn_idx=idx, q_to_main=q_to_main) + super().__init__(**kwargs) self._hostname = hostname - self._login = login + self.login = login self._socket: Optional[socket] = None self._assumed_open = False self._recv_loop: Optional[_RecvLoop] = None - self.broadcast_conn(InitConnWindowEvent, self._login) self._start_connecting() def _start_connecting(self) -> None: def connect(self) -> None: self._socket = socket() - self.broadcast_conn(LogConnEvent, - f'Connecting to {self._hostname} …') + self.broadcast(LogConnEvent, + f'Connecting to {self._hostname} …') self._socket.settimeout(_TIMEOUT_CONNECT) try: self._socket.connect((self._hostname, _PORT)) except (TimeoutError, socket_gaierror) as e: - self.broadcast_conn(LogConnEvent, f'ALERT: {e}') + self.broadcast(LogConnEvent, f'ALERT: {e}') return self._socket.settimeout(TIMEOUT_LOOP) self._assumed_open = True - self._recv_loop = _RecvLoop(self, + self._recv_loop = _RecvLoop(conn=self, q_to_main=self._q_to_main, bonus_iterator=self._read_lines()) - self.broadcast_conn(_ConnectedEvent) + self.broadcast(_ConnectedEvent) Thread(target=connect, daemon=True, args=(self,)).start() + def broadcast[E: Event](self, + event_class: type[E], + *args, **kwargs + ) -> None: + 'Broadcast event subclassing ConnEvent, with self as its .conn.' + super().broadcast(event_class, conn=self, *args, **kwargs) + + def send(self, verb: str, parameters: tuple[str, ...]) -> None: + 'Broadcast _SendEvent for _IrcMessage(verb, parameters).' + self.broadcast(_SendEvent, _IrcMessage(verb, parameters)) + def update_login(self, **kwargs) -> None: - 'Adapt ._login attributes to kwargs, broadcast NickSetEvent.' + 'Adapt .login attributes to kwargs, broadcast NickSetEvent.' for key, val in kwargs.items(): - setattr(self._login, key, val) - self.broadcast_conn(NickSetEvent) + setattr(self.login, key, val) + self.broadcast(NickSetEvent) def close(self) -> None: 'Close both RecvLoop and socket.' @@ -285,9 +273,8 @@ class IrcConnection(BroadcastConnMixin): def _write_line(self, line: str) -> None: 'Send line-separator-delimited message over socket.' if not (self._socket and self._assumed_open): - self.broadcast_conn(LogConnEvent, - 'ALERT: cannot send, assuming connection ' - 'closed') + self.broadcast(LogConnEvent, + 'ALERT: cannot send, assuming connection closed.') return self._socket.sendall(line.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR) @@ -295,39 +282,35 @@ class IrcConnection(BroadcastConnMixin): 'Process connection-directed Event into further steps.' if isinstance(event, InitReconnectEvent): if self._assumed_open: - self.broadcast_conn(LogConnEvent, - 'ALERT: Reconnect called, but still seem ' - 'connected, so nothing to do.') + self.broadcast(LogConnEvent, + 'ALERT: Reconnect called, but still seem ' + 'connected, so nothing to do.') else: self._start_connecting() elif isinstance(event, _ConnectedEvent): # self.send('CAP', ('LS', '302')) - self.send('USER', (self._login.user, '0', '*', self._login.real)) - self.send('NICK', (self._login.nick,)) + self.send('USER', (self.login.user, '0', '*', self.login.real)) + self.send('NICK', (self.login.nick,)) # self.send('CAP', ('LIST',)) # self.send('CAP', ('END',)) elif isinstance(event, _DisconnectedEvent): self.close() elif isinstance(event, _SendEvent): - self.broadcast_conn(LogConnEvent, f'->: {event.payload.raw}') + self.broadcast(LogConnEvent, f'->: {event.payload.raw}') self._write_line(event.payload.raw) -class _RecvLoop(Loop): +class _RecvLoop(Loop, ConnMixin): 'Loop to react on messages from server.' - def __init__(self, conn: IrcConnection, **kwargs) -> None: - super().__init__(**kwargs) - self._conn = conn - def process_bonus(self, yielded: str) -> None: msg = _IrcMessage.from_raw(yielded) - self._conn.broadcast_conn(LogConnEvent, f'<-: {msg.raw}') + self.conn.broadcast(LogConnEvent, f'<-: {msg.raw}') if msg.verb == 'PING': - self._conn.send('PONG', (msg.parameters[0],)) + self.conn.send('PONG', (msg.parameters[0],)) elif msg.verb == 'ERROR'\ and msg.parameters[0].startswith('Closing link:'): - self._conn.broadcast_conn(_DisconnectedEvent) + self.conn.broadcast(_DisconnectedEvent) elif msg.verb in {'001', 'NICK'}: - self._conn.update_login(nick=msg.parameters[0], - nick_confirmed=True) + self.conn.update_login(nick=msg.parameters[0], + nick_confirmed=True) diff --git a/ircplom/tui.py b/ircplom/tui.py index 3c1a802..d05db64 100644 --- a/ircplom/tui.py +++ b/ircplom/tui.py @@ -13,9 +13,8 @@ from blessed import Terminal as BlessedTerminal from ircplom.events import (BroadcastMixin, Event, EventQueue, Loop, PayloadMixin, QuitEvent) from ircplom.irc_conn import ( - BroadcastConnMixin, ConnEvent, InitConnectEvent, InitConnWindowEvent, - InitReconnectEvent, LoginNames, LogConnEvent, NickSetEvent, - TIMEOUT_LOOP) + ConnEvent, ConnMixin, InitConnectEvent, InitReconnectEvent, + LoginNames, LogConnEvent, NickSetEvent, TIMEOUT_LOOP) _MIN_HEIGHT = 4 _MIN_WIDTH = 32 @@ -73,8 +72,8 @@ class _Widget(ABC): 'Defines most basic TUI object API.' @abstractmethod - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) self.tainted = True self._drawable = False @@ -98,8 +97,8 @@ class _ScrollableWidget(_Widget, ABC): 'Defines some API shared between _PromptWidget and _LogWidget.' _history_idx: int - def __init__(self, write: Callable[..., None], *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self, write: Callable[..., None], **kwargs) -> None: + super().__init__(**kwargs) self._write = write self._history: list[str] = [] @@ -121,9 +120,9 @@ class _LogWidget(_ScrollableWidget): _view_size: _YX _y_pgscroll: int - def __init__(self, wrap: Callable[[str], list[str]], *args, **kwargs + def __init__(self, wrap: Callable[[str], list[str]], **kwargs ) -> None: - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self._wrap = wrap self._wrapped_idx = self._history_idx = -1 self._wrapped: list[tuple[Optional[int], str]] = [] @@ -201,8 +200,8 @@ class _PromptWidget(_ScrollableWidget): _input_buffer_unsafe: str _cursor_x: int - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) self._reset_buffer('') @property @@ -310,14 +309,13 @@ class _PromptWidget(_ScrollableWidget): return to_return -class _ConnectionPromptWidget(_PromptWidget): +class _ConnectionPromptWidget(_PromptWidget, ConnMixin): 'PromptWidget with attributes, methods for dealing with an IrcConnection.' - login: LoginNames @property def _prompt(self) -> str: - return ((' ' if self.login.nick_confirmed else '?') - + self.login.nick + return ((' ' if self.conn.login.nick_confirmed else '?') + + self.conn.login.nick + super()._prompt) @@ -330,8 +328,9 @@ class _Window(_Widget): super().__init__(**kwargs) self.idx = idx self._term = term - self.log = _LogWidget(self._term.wrap, self._term.write) - self.prompt = self.__annotations__['prompt'](self._term.write) + self.log = _LogWidget(wrap=self._term.wrap, write=self._term.write) + self.prompt = self.__annotations__['prompt'](write=self._term.write, + **kwargs) if hasattr(self._term, 'size'): self.set_geometry() @@ -383,25 +382,21 @@ class _Window(_Widget): widget.draw() -class _ConnectionWindow(_Window, BroadcastConnMixin): +class _ConnectionWindow(_Window, ConnMixin): 'Window with attributes and methods for dealing with an IrcConnection.' prompt: _ConnectionPromptWidget - def __init__(self, login: LoginNames, **kwargs) -> None: - super().__init__(**kwargs) - self.prompt.login = login - def cmd__disconnect(self, quit_msg: str = 'ircplom says bye') -> None: 'Send QUIT command to server.' - self.send('QUIT', (quit_msg, )) + self.conn.send('QUIT', (quit_msg, )) def cmd__reconnect(self) -> None: 'Attempt reconnection.' - self.broadcast_conn(InitReconnectEvent) + self.conn.broadcast(InitReconnectEvent) def cmd__nick(self, new_nick: str) -> None: 'Attempt nickname change.' - self.send('NICK', (new_nick, )) + self.conn.send('NICK', (new_nick, )) class _KeyboardLoop(Loop, BroadcastMixin): @@ -472,17 +467,18 @@ class _TuiLoop(Loop, BroadcastMixin): cmd = self._cmd_name_to_cmd(event.payload[0]) assert cmd is not None cmd(*event.payload[1:]) - elif isinstance(event, InitConnWindowEvent): - conn_win = _ConnectionWindow(q_to_main=self._q_to_main, - conn_idx=event.conn_idx, - idx=len(self._windows), - term=self._term, - login=event.payload) - self._windows += [conn_win] - self._conn_windows += [conn_win] - self._switch_window(conn_win.idx) elif isinstance(event, ConnEvent): - conn_win = self._conn_windows[event.conn_idx] + matching_wins = [cw for cw in self._conn_windows + if cw.conn == event.conn] + if matching_wins: + conn_win = matching_wins[0] + else: + conn_win = _ConnectionWindow(idx=len(self._windows), + conn=event.conn, + term=self._term) + self._windows += [conn_win] + self._conn_windows += [conn_win] + self._switch_window(conn_win.idx) if isinstance(event, LogConnEvent): conn_win.log.append(event.payload) elif isinstance(event, NickSetEvent): -- 2.30.2