home · contact · privacy
Turn into installable by way of plomlib's install procedures.
authorChristian Heller <c.heller@plomlompom.de>
Wed, 24 Sep 2025 10:48:00 +0000 (12:48 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Wed, 24 Sep 2025 10:48:00 +0000 (12:48 +0200)
23 files changed:
.gitmodules [new file with mode: 0644]
install.sh [new file with mode: 0755]
ircplom.py [deleted file]
ircplom/__init__.py [deleted file]
ircplom/client.py [deleted file]
ircplom/client_tui.py [deleted file]
ircplom/events.py [deleted file]
ircplom/irc_conn.py [deleted file]
ircplom/msg_parse_expectations.py [deleted file]
ircplom/testing.py [deleted file]
ircplom/tui_base.py [deleted file]
plomlib [new submodule]
requirements.txt [deleted file]
src/ircplom/__init__.py [new file with mode: 0644]
src/ircplom/client.py [new file with mode: 0644]
src/ircplom/client_tui.py [new file with mode: 0644]
src/ircplom/events.py [new file with mode: 0644]
src/ircplom/irc_conn.py [new file with mode: 0644]
src/ircplom/msg_parse_expectations.py [new file with mode: 0644]
src/ircplom/testing.py [new file with mode: 0644]
src/ircplom/tui_base.py [new file with mode: 0644]
src/requirements.txt [new file with mode: 0644]
src/run.py [new file with mode: 0755]

diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..42cf7f3
--- /dev/null
@@ -0,0 +1,3 @@
+[submodule "plomlib"]
+       path = plomlib
+       url = https://plomlompom.com/repos/clone/plomlib
diff --git a/install.sh b/install.sh
new file mode 100755 (executable)
index 0000000..40deca4
--- /dev/null
@@ -0,0 +1,3 @@
+#!/usr/bin/sh
+./plomlib/sh/install.sh ircplom
+ircplom install_deps
diff --git a/ircplom.py b/ircplom.py
deleted file mode 100755 (executable)
index 42f9dc9..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env python3
-'Attempt at an IRC client.'
-from queue import SimpleQueue
-from sys import argv
-from ircplom.events import ExceptionEvent, QuitEvent
-from ircplom.client import ClientsDb, ClientEvent, NewClientEvent
-from ircplom.tui_base import BaseTui, Terminal, TerminalInterface, TuiEvent
-from ircplom.client_tui import ClientTui
-from ircplom.testing import TestTerminal, TestingClientTui
-
-
-def main_loop(cls_term: type[TerminalInterface], cls_tui: type[BaseTui]
-              ) -> None:
-    'Main execution code / loop.'
-    q_events: SimpleQueue = SimpleQueue()
-    clients_db: ClientsDb = {}
-    try:
-        with cls_term(_q_out=q_events).setup() as term:
-            tui = cls_tui(_q_out=q_events, term=term)
-            while True:
-                event = q_events.get()
-                if isinstance(event, QuitEvent):
-                    break
-                if isinstance(event, ExceptionEvent):
-                    raise event.exception
-                if isinstance(event, TuiEvent):
-                    event.affect(tui)
-                elif isinstance(event, NewClientEvent):
-                    event.affect(clients_db)
-                elif isinstance(event, ClientEvent):
-                    event.affect(clients_db[event.client_id])
-    finally:
-        for client in clients_db.values():
-            client.close()
-
-
-if __name__ == '__main__':
-    if len(argv) > 1 and argv[1] == 'test':
-        main_loop(TestTerminal, TestingClientTui)
-        print('test finished')
-    else:
-        main_loop(Terminal, ClientTui)
diff --git a/ircplom/__init__.py b/ircplom/__init__.py
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/ircplom/client.py b/ircplom/client.py
deleted file mode 100644 (file)
index d0bc4b8..0000000
+++ /dev/null
@@ -1,984 +0,0 @@
-'High-level IRC protocol / server connection management.'
-# built-ins
-from abc import ABC, abstractmethod
-from base64 import b64encode
-from dataclasses import dataclass, InitVar
-from getpass import getuser
-from threading import Thread
-from typing import (Any, Callable, Collection, Generic, Iterable, Iterator,
-                    Optional, Self, Set, TypeVar)
-# ourselves
-from ircplom.events import (
-    AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin)
-from ircplom.irc_conn import (
-    BaseIrcConnection, IrcConnAbortException, IrcMessage,
-    ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL)
-from ircplom.msg_parse_expectations import MSG_EXPECTATIONS
-
-
-_NAMES_DESIRED_SERVER_CAPS = ('sasl',)
-
-
-class SendFail(BaseException):
-    'When Client.send fails.'
-
-
-class TargetUserOffline(BaseException):
-    'When according to server our target user is not to be found.'
-
-
-class ImplementationFail(BaseException):
-    'When no matching parser found for server message.'
-
-
-class AutoAttrMixin:
-    'Ensures attribute as defined by annotations along MRO'
-
-    @classmethod
-    def _deep_annotations(cls) -> dict[str, type]:
-        types: dict[str, type] = {}
-        for c in cls.__mro__:
-            if hasattr(c, '__annotations__'):
-                types = c.__annotations__ | types
-        return {k: v for k, v in types.items() if k[0] != '_'}
-
-    def __getattribute__(self, key: str):
-        if key[0] != '_' and (cls := self._deep_annotations().get(key, None)):
-            try:
-                return super().__getattribute__(key)
-            except AttributeError:
-                setattr(self, key, self._make_attr(cls, key))
-        return super().__getattribute__(key)
-
-
-class _Clearable(ABC):
-
-    @abstractmethod
-    def clear(self) -> None:
-        'Zero internal knowledge.'
-
-
-DictItem = TypeVar('DictItem')
-
-
-class Dict(_Clearable, Generic[DictItem]):
-    'Customized dict replacement.'
-
-    def __init__(self, **kwargs) -> None:
-        self._dict: dict[str, DictItem] = {}
-        super().__init__(**kwargs)
-
-    def keys(self) -> tuple[str, ...]:
-        'Keys of item registrations.'
-        return tuple(self._dict.keys())
-
-    def __getitem__(self, key: str) -> DictItem:
-        return self._dict[key]
-
-    def clear(self) -> None:
-        self._dict.clear()
-
-    @property
-    def _item_cls(self):
-        orig_cls = (self.__orig_class__ if hasattr(self, '__orig_class__')
-                    else self.__orig_bases__[0])
-        return orig_cls.__args__[0]
-
-
-class _Dict(Dict[DictItem]):
-
-    def __setitem__(self, key: str, val: DictItem) -> None:
-        assert isinstance(val, self._item_cls)
-        self._dict[key] = val
-
-    def __delitem__(self, key: str) -> None:
-        del self._dict[key]
-
-    def values(self) -> tuple[DictItem, ...]:
-        'Items registered.'
-        return tuple(self._dict.values())
-
-    @staticmethod
-    def key_val_from_eq_str(eq_str: str) -> tuple[str, str]:
-        'Split eq_str by "=", and if none, into eq_str and "".'
-        toks = eq_str.split('=', maxsplit=1)
-        return toks[0], '' if len(toks) == 1 else toks[1]
-
-
-class _Completable(ABC):
-    completed: Any
-
-    @abstractmethod
-    def complete(self) -> None:
-        'Set .completed to "complete" value if possible of current state.'
-
-
-class _CompletableStringsCollection(_Completable, Collection):
-    _collected: Collection[str]
-    completed: Optional[Collection[str]] = None
-
-    @abstractmethod
-    def _copy_collected(self) -> Collection[str]:
-        pass
-
-    def complete(self) -> None:
-        self.completed = self._copy_collected()
-
-    def __len__(self) -> int:
-        assert self.completed is not None
-        return len(self.completed)
-
-    def __contains__(self, item) -> bool:
-        assert self.completed is not None
-        assert isinstance(item, str)
-        return item in self.completed
-
-    def __iter__(self) -> Iterator[str]:
-        assert self.completed is not None
-        yield from self.completed
-
-
-class _CompletableStringsSet(_CompletableStringsCollection, Set):
-    _collected: set[str]
-    completed: Optional[set[str]] = None
-
-    def __init__(self) -> None:
-        self._collected = set()
-
-    def _copy_collected(self) -> set[str]:
-        return set(self._collected)
-
-    def _on_set(self, m_name: str, item: str, on_complete: bool, exists: bool
-                ) -> None:
-        method = getattr(self._collected, m_name)
-        if on_complete:
-            assert self.completed is not None
-            assert (item in self.completed) == exists
-            assert (item in self._collected) == exists
-            method(item)
-            self.complete()
-        else:
-            assert self.completed is None
-            assert (item in self._collected) == exists
-            method(item)
-
-    def completable_add(self, item: str, on_complete: bool) -> None:
-        'Put item into collection.'
-        self._on_set('add', item, on_complete, False)
-
-    def completable_remove(self, item: str, on_complete: bool) -> None:
-        'Remove item from collection.'
-        self._on_set('remove', item, on_complete, True)
-
-    def intersection(self, *others: Iterable[str]) -> set[str]:
-        'Compare self to other set(s).'
-        assert self.completed is not None
-        result = self.completed
-        for other in others:
-            assert isinstance(other, set)
-            result = result.intersection(other)
-        return result
-
-
-class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection):
-    _collected: tuple[str, ...] = tuple()
-    completed: Optional[tuple[str, ...]] = tuple()
-
-    def _copy_collected(self) -> tuple[str, ...]:
-        return tuple(self._collected)
-
-    def append(self, value: str, complete=False) -> None:
-        'Append value to list.'
-        self._collected += (value,)
-        if complete:
-            self.complete()
-
-    def clear(self) -> None:
-        self._collected = tuple()
-        self.complete()
-
-
-class _UpdatingMixin:
-
-    def __init__(self, on_update: Callable, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._on_update = on_update
-
-
-class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin):
-
-    def _make_attr(self, cls: Callable, key: str):
-        return cls(on_update=lambda *steps: self._on_update(key, *steps))
-
-    def __setattr__(self, key: str, value) -> None:
-        super().__setattr__(key, value)
-        if hasattr(self, '_on_update') and key in self._deep_annotations():
-            self._on_update(key)
-
-    def attr_names(self) -> tuple[str, ...]:
-        'Names of (deeply) annotated attributes.'
-        return tuple(self._deep_annotations().keys())
-
-
-class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]):
-    _create_if_none: Optional[dict[str, Any]] = None
-
-    def __bool__(self) -> bool:
-        return bool(self._dict)
-
-    def __getitem__(self, key: str) -> DictItem:
-        if key not in self._dict:
-            if self._create_if_none is not None:
-                kw = {} | self._create_if_none
-                if _UpdatingMixin in self._item_cls.__mro__:
-                    kw |= {'on_update':
-                           lambda *steps: self._on_update(key, *steps)}
-                self[key] = self._item_cls(**kw)
-        return super().__getitem__(key)
-
-    def __setitem__(self, key: str, val: DictItem) -> None:
-        super().__setitem__(key, val)
-        self._on_update(key)
-
-    def __delitem__(self, key: str) -> None:
-        super().__delitem__(key)
-        self._on_update(key)
-
-    def clear(self) -> None:
-        super().clear()
-        self._on_update()
-
-
-class _UpdatingCompletable(_UpdatingMixin, _Completable):
-
-    def complete(self) -> None:
-        super().complete()  # type: ignore
-        self._on_update()
-
-
-class _UpdatingCompletableStringsSet(
-        _UpdatingCompletable, _CompletableStringsSet):
-    pass
-
-
-class _UpdatingCompletableStringsOrdered(
-        _UpdatingCompletable, _CompletableStringsOrdered):
-    pass
-
-
-@dataclass
-class IrcConnSetup:
-    'All we need to know to set up a new Client connection.'
-    hostname: str = ''
-    port: int = 0
-    nick_wanted: str = ''
-    user_wanted: str = ''
-    realname: str = ''
-    password: str = ''
-
-
-class ChatMessage:
-    'Collects all we want to know on incoming PRIVMSG or NOTICE chat message.'
-    content: str = ''
-    sender: str = ''
-    target: str = ''
-    is_notice: bool = False
-
-    def __str__(self) -> str:
-        return f'{"N" if self.is_notice else "P"}|'\
-                + f'{self.sender}|{self.target}|{self.content}'
-
-    def __bool__(self) -> bool:
-        return bool(self.content + self.sender + self.target) | self.is_notice
-
-
-class SharedClientDbFields(IrcConnSetup):
-    'API for fields shared directly in name and type with TUI.'
-    connection_state: str = ''
-    isupport: Dict[str]
-    motd: Iterable[str]
-    sasl_account: str = ''
-    sasl_auth_state: str = ''
-    message: ChatMessage = ChatMessage()
-
-    def is_chan_name(self, name: str) -> bool:
-        'Tests name to match CHANTYPES prefixes.'
-        return name[0] in self.isupport['CHANTYPES']
-
-
-@dataclass
-class NickUserHost:
-    'Combination of nickname, username on host, and host.'
-    nick: str = '?'
-    user: str = '?'
-    host: str = '?'
-
-    def __str__(self) -> str:
-        return f'{self.nick}!{self.user}@{self.host}'
-
-
-class User(NickUserHost):
-    'Adds to NickUserHost non-naming-specific attributes.'
-    modes: str = '?'
-    exit_msg: str = ''
-
-
-@dataclass
-class ServerCapability:
-    'Public API for CAP data.'
-    data: str = ''
-    enabled: bool = False
-
-
-@dataclass
-class Topic:
-    'Collects both setter and content of channel topic.'
-    what: str = ''
-    who: Optional[NickUserHost] = None
-
-
-class Channel:
-    'Collects .topic, and in .user_ids inhabitant IDs.'
-    topic: Topic
-    user_ids: Iterable[str]
-    exits: Dict[str]
-
-
-@dataclass
-class _ClientIdMixin:
-    'Collects a Client\'s ID at .client_id.'
-    client_id: str
-
-
-@dataclass
-class ClientQueueMixin(QueueMixin, _ClientIdMixin):
-    'To QueueMixin adds _cput to send ClientEvent for self.'
-
-    def _client_trigger(self, t_method: str, **kwargs) -> None:
-        self._put(ClientEvent.affector(t_method, client_id=self.client_id
-                                       ).kw(**kwargs))
-
-
-@dataclass
-class IrcConnection(BaseIrcConnection, _ClientIdMixin):
-    'Parent extended to work with Client.'
-    hostname: InitVar[str]  # needed by BaseIrcConnection, but not desired as
-    port: InitVar[int]      # dataclass fields, only for __post_init__ call
-
-    def __post_init__(self, hostname, port, **kwargs) -> None:
-        super().__init__(hostname=hostname, port=port, _q_out=self._q_out,
-                         **kwargs)
-
-    def _make_recv_event(self, msg: IrcMessage) -> 'ClientEvent':
-        return ClientEvent.affector('handle_msg', client_id=self.client_id
-                                    ).kw(msg=msg)
-
-    def _on_handled_loop_exception(self, e: IrcConnAbortException
-                                   ) -> 'ClientEvent':
-        return ClientEvent.affector('on_handled_loop_exception',
-                                    client_id=self.client_id).kw(e=e)
-
-
-class _CompletableTopic(_Completable, Topic):
-    completed: Optional[Topic] = None
-
-    def complete(self) -> None:
-        assert self.who is not None
-        copy = _NickUserHost.from_str(str(self.who))
-        self.completed = Topic(self.what,
-                               NickUserHost(copy.nick, copy.user, copy.host))
-
-
-class _Channel(Channel):
-    user_ids: _CompletableStringsSet
-    topic: _CompletableTopic
-    exits: _Dict[str]
-
-    def __init__(self,
-                 userid_for_nickuserhost: Callable,
-                 get_membership_prefixes: Callable,
-                 purge_users: Callable,
-                 **kwargs
-                 ) -> None:
-        self._userid_for_nickuserhost = userid_for_nickuserhost
-        self._get_membership_prefixes = get_membership_prefixes
-        self.purge_users = purge_users
-        super().__init__(**kwargs)
-
-    def add_from_namreply(self, items: tuple[str, ...]) -> None:
-        'Add to .user_ids items assumed as nicknames with membership prefixes.'
-        for item in items:
-            n_u_h = NickUserHost(item.lstrip(self._get_membership_prefixes()))
-            user_id = self._userid_for_nickuserhost(n_u_h, create_if_none=True)
-            self.user_ids.completable_add(user_id, on_complete=False)
-
-    def add_user(self, user: '_User') -> None:
-        'To .user_ids add user.nickname, keep .user_ids declared complete.'
-        user_id = self._userid_for_nickuserhost(user, create_if_none=True,
-                                                updating=True)
-        self.user_ids.completable_add(user_id, on_complete=True)
-
-    def remove_user(self, user: '_User', msg: str) -> None:
-        'From .user_ids remove .nickname, keep .user_ids declared complete.'
-        self.exits[user.id_] = msg
-        self.user_ids.completable_remove(user.id_, on_complete=True)
-        del self.exits[user.id_]
-        self.purge_users()
-
-
-class _ChatMessage(ChatMessage):
-
-    def __init__(self,
-                 sender: str | NickUserHost = '',
-                 db: Optional['_ClientDb'] = None
-                 ) -> None:
-        self.sender = sender if isinstance(sender, str) else sender.nick
-        self._db = db
-
-    def to(self, target: str) -> Self:
-        'Extend self with .target, return self.'
-        self.target = target
-        return self
-
-    def __setattr__(self, key: str, value: str) -> None:
-        if key in {'privmsg', 'notice'}:
-            assert self._db is not None
-            self.is_notice = key == 'notice'
-            self.content = value
-            self._db.message = self
-            # to clean update cache, enabling equal messages in direct sequence
-            self._db.message = ChatMessage()
-        else:
-            super().__setattr__(key, value)
-
-
-class _SetNickuserhostMixin:
-
-    def __setattr__(self, key: str, value: NickUserHost | str) -> None:
-        if key == 'nickuserhost' and isinstance(value, NickUserHost):
-            for annotated_key in NickUserHost.__annotations__:
-                setattr(self, annotated_key, getattr(value, annotated_key))
-        else:
-            super().__setattr__(key, value)
-
-
-class _NickUserHost(NickUserHost):
-
-    @staticmethod
-    def possible_from(value: str) -> bool:
-        'If class instance could be parsed from value.'
-        toks = value.split('!')
-        if not len(toks) == 2:
-            return False
-        toks = toks[1].split('@')
-        if not len(toks) == 2:
-            return False
-        return True
-
-    @classmethod
-    def from_str(cls, value: str) -> Self:
-        'Produce from string assumed to fit _!_@_ pattern.'
-        assert cls.possible_from(value)
-        toks = value.split('!')
-        toks = toks[0:1] + toks[1].split('@')
-        return cls(*toks)
-
-    @property
-    def incremented(self) -> str:
-        'Return .nick with number suffix incremented, or "0" if none.'
-        name, digits = ([(self.nick, '')]
-                        + [(self.nick[:i], self.nick[i:])
-                           for i in range(len(self.nick), 0, -1)
-                           if self.nick[i:].isdigit()]
-                        )[-1]
-        return name + str(0 if not digits else (int(digits) + 1))
-
-
-class _User(_SetNickuserhostMixin, User):
-
-    def __init__(self,
-                 names_channels_of_user: Callable,
-                 remove_from_channels: Callable,
-                 **kwargs) -> None:
-        self.names_channels = lambda: names_channels_of_user(self)
-        self._remove_from_channels = lambda target, msg: remove_from_channels(
-                self, target, msg)
-        super().__init__(**kwargs)
-
-    def part(self, channel_name: str, exit_msg: str) -> None:
-        'First set .exit_msg, then remove from channel of channel_name.'
-        self._remove_from_channels(channel_name, f'P{exit_msg}')
-
-    def quit(self, exit_msg: str) -> None:
-        'First set .exit_msg, then remove from any channels.'
-        self.exit_msg = f'Q{exit_msg}'
-        self._remove_from_channels('', self.exit_msg)
-
-    @property
-    def id_(self) -> str:
-        'To be set to key inside dictionary if placed into one.'
-        return self._id_
-
-    @id_.setter
-    def id_(self, value: str) -> None:
-        self._id_ = value
-
-
-class _UpdatingServerCapability(_UpdatingAttrsMixin, ServerCapability):
-    pass
-
-
-class _UpdatingCompletableTopic(_UpdatingCompletable, _CompletableTopic):
-    pass
-
-
-class _UpdatingChannel(_UpdatingAttrsMixin, _Channel):
-    user_ids: _UpdatingCompletableStringsSet
-    topic: _UpdatingCompletableTopic
-    exits: _UpdatingDict[str]
-
-
-class _UpdatingUser(_UpdatingAttrsMixin, _User):
-    pass
-
-
-class _UpdatingUsersDict(_UpdatingDict[_UpdatingUser]):
-    _top_id: int
-    userlen: int
-
-    def __getitem__(self, key: str) -> _UpdatingUser:
-        user = super().__getitem__(key)
-        user.id_ = key
-        return user
-
-    def clear(self) -> None:
-        super().clear()
-        self._top_id = 0
-        self._on_update()
-
-    def id_for_nickuserhost(self,
-                            nickuserhost: NickUserHost,
-                            create_if_none=False,
-                            allow_none=False,
-                            updating=False
-                            ) -> Optional[str]:
-        'Return user_id for nickuserhost.nick, create if none, maybe update.'
-        matches = [id_ for id_, user in self._dict.items()
-                   if user.nick == nickuserhost.nick]
-        assert len(matches) in ({0, 1} if (create_if_none or allow_none)
-                                else {1})
-        if len(matches) == 1:
-            id_ = matches[0]
-            if '?' in {nickuserhost.user, nickuserhost.host}:
-                assert nickuserhost.user == nickuserhost.host  # both are '?'
-                # only provided with .nick, no fields we could update
-                return id_
-            stored = self._dict[id_]
-            # .nick by definition same, check other fields for updatability;
-            # allow where '?', or for set .user only to add "~" prefix, assert
-            # nothing else could have changed
-            if stored.user == '?'\
-                    or nickuserhost.user == f'~{stored.user}'[:self.userlen]:
-                assert updating
-                stored.user = nickuserhost.user
-            else:
-                assert stored.user == nickuserhost.user
-            if stored.host == '?':
-                assert updating
-                stored.host = nickuserhost.host
-            else:
-                assert stored.host == nickuserhost.host
-        elif create_if_none:
-            self._top_id += 1
-            id_ = str(self._top_id)
-            self[id_].nickuserhost = nickuserhost
-        else:
-            return None
-        return id_
-
-    def purge(self) -> None:
-        'Remove all not linked to by existing channels, except of our ID "me".'
-        for id_ in [id_ for id_, user in self._dict.items()
-                    if id_ != 'me' and not user.names_channels()]:
-            del self[id_]
-
-
-class _UpdatingChannelsDict(_UpdatingDict[_UpdatingChannel]):
-
-    def _of_user(self, user: _User) -> dict[str, _UpdatingChannel]:
-        return {k: v for k, v in self._dict.items() if user.id_ in v.user_ids}
-
-    def of_user(self, user: _User) -> tuple[str, ...]:
-        'Return names of channels listing user as member.'
-        return tuple(self._of_user(user).keys())
-
-    def remove_user(self, user: _User, target: str, msg: str) -> None:
-        'Remove user from channel named "target", or all with user if empty.'
-        if target:
-            self[target].remove_user(user, msg)
-        else:
-            for channel in self._of_user(user).values():
-                channel.remove_user(user, msg)
-
-
-class _UpdatingIsupportDict(_UpdatingDict[str]):
-
-    def __delitem__(self, key: str) -> None:
-        if key in ISUPPORT_DEFAULTS:
-            self[key] = ISUPPORT_DEFAULTS[key]
-        else:
-            super().__delitem__(key)
-
-    def clear(self) -> None:
-        super().clear()
-        for key, value in ISUPPORT_DEFAULTS.items():
-            self[key] = value
-
-
-class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields):
-    _updates_cache: dict[tuple[str, ...], Any] = {}
-    _keep_on_clear = set(IrcConnSetup.__annotations__.keys())
-    caps: _UpdatingDict[_UpdatingServerCapability]
-    channels: _UpdatingChannelsDict
-    isupport: _UpdatingIsupportDict
-    motd: _UpdatingCompletableStringsOrdered
-    users: _UpdatingUsersDict
-
-    def __getattribute__(self, key: str):
-        attr = super().__getattribute__(key)
-        if key == 'channels' and attr._create_if_none is None\
-                and super().__getattribute__('users'
-                                             )._create_if_none is not None:
-            attr._create_if_none = {
-                    'userid_for_nickuserhost': self.users.id_for_nickuserhost,
-                    'get_membership_prefixes': self._get_membership_prefixes,
-                    'purge_users': self.users.purge}
-        elif key == 'users':
-            attr.userlen = int(self.isupport['USERLEN'])
-            if attr._create_if_none is None:
-                attr._create_if_none = {
-                        'names_channels_of_user': self.channels.of_user,
-                        'remove_from_channels': self.channels.remove_user}
-        elif key == 'caps' and attr._create_if_none is None:
-            attr._create_if_none = {}
-        return attr
-
-    def messaging(self, src: str | NickUserHost) -> ChatMessage:
-        'Start input chain for chat message data.'
-        return _ChatMessage(sender=src, db=self)
-
-    def into_endnode_updates(self, path: tuple[str, ...]
-                             ) -> list[tuple[tuple[str, ...], Any]]:
-        'Return path-value pairs for update-worthy (sub-)elements at path.'
-
-        def update_unless_cached(path: tuple[str, ...], val: Any
-                                 ) -> list[tuple[tuple[str, ...], Any]]:
-            if path not in self._updates_cache\
-                    or val != self._updates_cache[path]:
-                self._updates_cache[path] = val
-                return [(path, val)]
-            return []
-
-        parent = self
-        cache_path: tuple[str, ...] = tuple()
-        for step in path[:-1]:
-            cache_path = cache_path + (step,)
-            if cache_path in self._updates_cache:
-                del self._updates_cache[cache_path]
-            parent = (parent[step] if isinstance(parent, Dict)
-                      else getattr(parent, step))
-        last_step = path[-1]
-        val_at_path = ((parent[last_step] if last_step in parent.keys()
-                        else None) if isinstance(parent, Dict)
-                       else getattr(parent, last_step))
-        if not isinstance(val_at_path, _UpdatingMixin):
-            return update_unless_cached(path, val_at_path)
-        if isinstance(val_at_path, _Completable):
-            if val_at_path.completed is not None:
-                return update_unless_cached(path, val_at_path.completed)
-            return []
-        if isinstance(val_at_path, Dict) and not val_at_path.keys():
-            for cache_path in [p for p in self._updates_cache
-                               if len(p) > len(path)
-                               and p[:len(path)] == path]:
-                del self._updates_cache[cache_path]
-            return update_unless_cached(path, None)
-        if isinstance(val_at_path, Dict):
-            sub_items = val_at_path.keys()
-        else:
-            assert isinstance(val_at_path, _UpdatingAttrsMixin)
-            sub_items = val_at_path.attr_names()
-        updates = []
-        for key in sub_items:
-            p = path + (key,)
-            updates += self.into_endnode_updates(p)
-        return updates
-
-    def clear(self) -> None:
-        for key, value in [(k, v) for k, v in self._deep_annotations().items()
-                           if k not in self._keep_on_clear]:
-            if hasattr(value, '__origin__'):
-                value = value.__origin__
-            if issubclass(value, _Clearable):
-                getattr(self, key).clear()
-            elif issubclass(value, str):
-                setattr(self, key, '')
-
-    def is_nick(self, nick: str) -> bool:
-        'Tests name to match rules for nicknames.'
-        if len(nick) == 0:
-            return False
-        if nick[0] in (ILLEGAL_NICK_FIRSTCHARS
-                       + self.isupport['CHANTYPES']
-                       + self._get_membership_prefixes()):
-            return False
-        for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]:
-            return False
-        return True
-
-    def _get_membership_prefixes(self) -> str:
-        'Registered possible membership nickname prefixes.'
-        toks = self.isupport['PREFIX'].split(')', maxsplit=1)
-        assert len(toks) == 2
-        assert toks[0][0] == '('
-        return toks[1]
-
-
-class _CapsManager(_Clearable):
-
-    def __init__(self,
-                 sender: Callable,
-                 caps_dict: _UpdatingDict[_UpdatingServerCapability]
-                 ) -> None:
-        self._dict = caps_dict
-        self._send = lambda *args: sender('CAP', *args)
-        self.clear()
-
-    def clear(self) -> None:
-        self._dict.clear()
-        self._ls = _CompletableStringsSet()
-        self._list = _CompletableStringsSet()
-        self._list_expectations: dict[str, set[str]] = {
-                'ACK': set(), 'NAK': set()}
-
-    def start_negotation(self) -> None:
-        'Call .clear, send CAPS LS 302.'
-        self.clear()
-        self._send('LS', '302')
-
-    def end_negotiation(self) -> None:
-        'Stop negotation, without emptying caps DB.'
-        self._send('END')
-
-    def process_msg(self, verb: str, items: tuple[str, ...], complete: bool
-                    ) -> bool:
-        'Parse CAP message to negot. steps, DB inputs; return if successful.'
-        for item in items:
-            if verb == 'NEW':
-                key, data = _Dict.key_val_from_eq_str(item)
-                self._dict[key].data = data
-            elif verb == 'DEL':
-                del self._dict[item]
-            elif verb in {'ACK', 'NAK'}:
-                self._list_expectations[verb].add(item)
-        if verb in {'LS', 'LIST'}:
-            target = getattr(self, f'_{verb.lower()}')
-            for item in items:
-                target.completable_add(item, False)
-            if complete:
-                target.complete()
-                if target is self._ls:
-                    for cap_name in _NAMES_DESIRED_SERVER_CAPS:
-                        self._send('REQ', cap_name)
-                    self._send('LIST')
-                else:
-                    acks = self._list_expectations['ACK']
-                    naks = self._list_expectations['NAK']
-                    assert acks == self._list.intersection(acks)
-                    assert set() == self._list.intersection(naks)
-                    for key, data in [_Dict.key_val_from_eq_str(entry)
-                                      for entry in sorted(self._ls)]:
-                        self._dict[key].data = data
-                        self._dict[key].enabled = key in self._list
-                    return True
-        return False
-
-
-class Client(ABC, ClientQueueMixin):
-    'Abstracts socket connection, loop over it, and handling messages from it.'
-    conn: Optional[IrcConnection] = None
-    _cls_conn: type[IrcConnection] = IrcConnection
-
-    def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None:
-        self.client_id = conn_setup.hostname
-        super().__init__(client_id=self.client_id, **kwargs)
-        self.db = _ClientDb(on_update=self._on_update)
-        self.db.clear()
-        self.caps = _CapsManager(self.send, self.db.caps)
-        for k in conn_setup.__annotations__:
-            setattr(self.db, k, getattr(conn_setup, k))
-        if self.db.port <= 0:
-            self.db.port = PORT_SSL
-        if not self.db.user_wanted:
-            self.db.user_wanted = getuser()
-
-    def connect(self) -> None:
-        'Attempt to open connection, on success perform session init steps.'
-        self.db.connection_state = 'connecting'
-
-        def connect(self) -> None:
-            try:
-                self.conn = self._cls_conn(
-                    hostname=self.db.hostname, port=self.db.port,
-                    _q_out=self._q_out, client_id=self.client_id)
-            except IrcConnAbortException as e:
-                self.db.connection_state = f'failed to connect: {e}'
-            except Exception as e:  # pylint: disable=broad-exception-caught
-                self._put(ExceptionEvent(CrashingException(e)))
-            else:
-                self.db.connection_state = 'connected'
-                self.caps.start_negotation()
-                self.send('USER', self.db.user_wanted,
-                          '0', '*', self.db.realname)
-                self.send('NICK', self.db.nick_wanted,)
-
-        # Do this in a thread, not to block flow of other (e.g. TUI) events.
-        Thread(target=connect, daemon=True, args=(self,)).start()
-
-    def close(self) -> None:
-        'Close connection and wipe memory of its states.'
-        self.db.clear()
-        if self.conn:
-            self.conn.close()
-        self.conn = None
-
-    def on_handled_loop_exception(self, e: IrcConnAbortException) -> None:
-        'Gracefully handle broken connection.'
-        self.db.connection_state = f'broken: {e}'
-        self.close()
-
-    @abstractmethod
-    def _on_update(self, *path) -> None:
-        pass
-
-    @abstractmethod
-    def _alert(self, msg: str) -> None:
-        pass
-
-    def send(self, verb: str, *args) -> IrcMessage:
-        'Send msg over socket, on success log .raw.'
-        if not self.conn:
-            raise SendFail('cannot send, connection seems closed')
-        msg = IrcMessage(verb, args)
-        self.conn.send(msg)
-        return msg
-
-    def handle_msg(self, msg: IrcMessage) -> None:
-        'Log msg.raw, then process incoming msg into appropriate client steps.'
-        ret = {}
-        for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]:
-            result = ex.parse_msg(
-                    msg=msg,
-                    is_chan_name=self.db.is_chan_name,
-                    is_nick=self.db.is_nick,
-                    possible_nickuserhost=_NickUserHost.possible_from,
-                    into_nickuserhost=_NickUserHost.from_str)
-            if result is not None:
-                ret = result
-                break
-        if '_verb' not in ret:
-            raise ImplementationFail(f'No handler implemented for: {msg.raw}')
-        for n_u_h in ret['_nickuserhosts']:  # update, turn into proper users
-            if (id_ := self.db.users.id_for_nickuserhost(
-                    n_u_h, allow_none=True, updating=True)):
-                for ret_name in [k for k in ret if ret[k] is n_u_h]:
-                    ret[ret_name] = self.db.users[id_]
-        for verb in ('setattr', 'do', 'doafter'):
-            for task, tok_names in [t for t in ret['_tasks'].items()
-                                    if t[0].verb == verb]:
-                node = self
-                for step in task.path:
-                    key = ret[step] if step.isupper() else step
-                    node = (node[key] if isinstance(node, Dict)
-                            else (node(key) if callable(node)
-                                  else getattr(node, key)))
-                for tok_name in tok_names:
-                    if task.verb == 'setattr':
-                        setattr(node, tok_name, ret[tok_name])
-                    else:
-                        getattr(node, tok_name)()
-        if ret['_verb'] == '005':   # RPL_ISUPPORT
-            for item in ret['isupport']:
-                if item[0] == '-':
-                    del self.db.isupport[item[1:]]
-                else:
-                    key, data = _Dict.key_val_from_eq_str(item)
-                    self.db.isupport[key] = data
-        elif ret['_verb'] == '353':  # RPL_NAMREPLY
-            self.db.channels[ret['channel']].add_from_namreply(ret['names'])
-        elif ret['_verb'] == '372':  # RPL_MOTD
-            self.db.motd.append(ret['line'])
-        elif ret['_verb'] == '401':  # ERR_NOSUCHNICK
-            raise TargetUserOffline(ret['missing'])
-        elif ret['_verb'] == '432':  # ERR_ERRONEOUSNICKNAME
-            alert = 'nickname refused for bad format'
-            if 'nick' not in ret:
-                alert += ', giving up'
-                self.close()
-            self._alert(alert)
-        elif ret['_verb'] == '433':  # ERR_NICKNAMEINUSE
-            self._alert('nickname already in use, trying increment')
-            self.send('NICK', _NickUserHost(nick=ret['used']).incremented)
-        elif ret['_verb'] == 'AUTHENTICATE':
-            auth = b64encode((self.db.nick_wanted + '\0'
-                              + self.db.nick_wanted + '\0'
-                              + self.db.password
-                              ).encode('utf-8')).decode('utf-8')
-            self.send('AUTHENTICATE', auth)
-        elif ret['_verb'] == 'CAP':
-            if (self.caps.process_msg(verb=ret['subverb'], items=ret['items'],
-                                      complete='tbc' not in ret)
-                    and 'sasl' in self.db.caps.keys()
-                    and 'PLAIN' in self.db.caps['sasl'].data.split(',')):
-                if self.db.password:
-                    self.db.sasl_auth_state = 'attempting'
-                    self.send('AUTHENTICATE', 'PLAIN')
-                else:
-                    self.caps.end_negotiation()
-        elif ret['_verb'] == 'JOIN' and ret['joiner'] != self.db.users['me']:
-            self.db.channels[ret['channel']].add_user(ret['joiner'])
-        elif ret['_verb'] == 'NICK':
-            user_id = self.db.users.id_for_nickuserhost(ret['named'],
-                                                        updating=True)
-            assert user_id is not None
-            self.db.users[user_id].nick = ret['nick']
-            if user_id == 'me':
-                self.db.nick_wanted = ret['nick']
-        elif ret['_verb'] == 'PART':
-            ret['parter'].part(ret['channel'], ret.get('message', ''))
-            if ret['parter'] is self.db.users['me']:
-                del self.db.channels[ret['channel']]
-                self.db.users.purge()
-        elif ret['_verb'] == 'PING':
-            self.send('PONG', ret['reply'])
-        elif ret['_verb'] == 'QUIT':
-            ret['quitter'].quit(ret['message'])
-
-
-ClientsDb = dict[str, Client]
-
-
-@dataclass
-class NewClientEvent(AffectiveEvent):
-    'Put Client .payload into ClientsDb target.'
-    payload: 'Client'
-
-    def affect(self, target: ClientsDb) -> None:
-        target[self.payload.client_id] = self.payload
-        # only run _after_ spot in ClientsDb secure, for ClientEvents to target
-        self.payload.connect()
-
-
-@dataclass
-class ClientEvent(AffectiveEvent, _ClientIdMixin):
-    'To affect Client identified by ClientIdMixin.'
diff --git a/ircplom/client_tui.py b/ircplom/client_tui.py
deleted file mode 100644 (file)
index dbdd498..0000000
+++ /dev/null
@@ -1,544 +0,0 @@
-'TUI adaptions to Client.'
-# built-ins
-from enum import Enum, auto
-from getpass import getuser
-from pathlib import Path
-from typing import Any, Callable, Optional, Sequence
-# ourselves
-from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
-                              CMD_SHORTCUTS)
-from ircplom.client import (
-    AutoAttrMixin, Channel, ChatMessage, Client, ClientQueueMixin, Dict,
-    DictItem, ImplementationFail, IrcConnSetup, NewClientEvent, NickUserHost,
-    SendFail, ServerCapability, SharedClientDbFields, TargetUserOffline, User)
-from ircplom.irc_conn import IrcMessage
-
-CMD_SHORTCUTS['disconnect'] = 'window.disconnect'
-CMD_SHORTCUTS['join'] = 'window.join'
-CMD_SHORTCUTS['part'] = 'window.part'
-CMD_SHORTCUTS['nick'] = 'window.nick'
-CMD_SHORTCUTS['privmsg'] = 'window.privmsg'
-CMD_SHORTCUTS['reconnect'] = 'window.reconnect'
-CMD_SHORTCUTS['raw'] = 'window.raw'
-
-_LOG_PREFIX_SERVER = '$'
-_LOG_PREFIX_OUT = '>'
-_LOG_PREFIX_IN = '<'
-
-_PATH_LOGS = Path.home().joinpath('.local', 'share', 'ircplom', 'logs')
-
-
-class _LogScope(Enum):
-    'Where log messages should go.'
-    ALL = auto()
-    DEBUG = auto()
-    CHAT = auto()
-    USER = auto()
-    USER_NO_CHANNELS = auto()
-
-
-class _ClientWindow(Window, ClientQueueMixin):
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._title = f'{self.client_id} :DEBUG'
-
-    def log(self, msg: str) -> None:
-        super().log(msg)
-        ldir = _PATH_LOGS.joinpath(self._title)
-        if not ldir.exists():
-            ldir.mkdir(parents=True)
-        assert ldir.is_dir()
-        with ldir.joinpath(f'{self._last_today}.txt'
-                           ).open('a', encoding='utf8') as f:
-            f.write(msg + '\n')
-
-    def _send_msg(self, verb: str, params: tuple[str, ...]) -> None:
-        self._client_trigger('send_w_params_tuple', verb=verb, params=params)
-
-    def cmd__disconnect(self, quit_msg: str = 'ircplom says bye') -> None:
-        'Send QUIT command to server.'
-        self._send_msg('QUIT', (quit_msg,))
-
-    def cmd__reconnect(self) -> None:
-        'Attempt reconnection.'
-        self._client_trigger('reconnect')
-
-    def cmd__nick(self, new_nick: str) -> None:
-        'Attempt nickname change.'
-        self._send_msg('NICK', (new_nick,))
-
-    def cmd__join(self, channel: str) -> None:
-        'Attempt joining a channel.'
-        self._send_msg('JOIN', (channel,))
-
-    def cmd__privmsg(self, target: str, msg: str) -> None:
-        'Send chat message msg to target.'
-        self._client_trigger('privmsg', chat_target=target, msg=msg)
-
-    def cmd__raw(self, verb: str, params_str: str = '') -> None:
-        'Send raw command, with direct input of params string.'
-        if params_str[:1] == ':':
-            params = [params_str]
-        else:
-            params = params_str.split(' :', maxsplit=1)
-            params = params[0].split() + params[1:2]
-        self._send_msg(verb, tuple(params))
-
-
-class _ChatPrompt(PromptWidget):
-    _nickname: str = ''
-
-    @property
-    def prefix(self) -> str:
-        return f'[{self._nickname}] '
-
-    def set_prefix_data(self, nick: str) -> None:
-        'Update prompt prefix with nickname data.'
-        if nick != self._nickname:
-            self._tainted = True
-            self._nickname = nick
-
-    def enter(self) -> str:
-        to_return = super().enter()
-        if (not to_return) or to_return[0:1] == '/':
-            return to_return
-        return f'/window.chat {to_return}'
-
-
-class _ChatWindow(_ClientWindow):
-    prompt: _ChatPrompt
-
-    def __init__(self, chatname: str, get_nick_data: Callable, **kwargs
-                 ) -> None:
-        self.chatname = chatname
-        self._get_nick_data = get_nick_data
-        super().__init__(**kwargs)
-        self._title = f'{self.client_id} {self.chatname}'
-        self.set_prompt_prefix()
-
-    def set_prompt_prefix(self) -> None:
-        'Look up relevant DB data to update prompt prefix.'
-        self.prompt.set_prefix_data(self._get_nick_data())
-
-    def cmd__chat(self, msg: str) -> None:
-        'PRIVMSG to target identified by .chatname.'
-        self.cmd__privmsg(target=self.chatname, msg=msg)
-
-
-class _ChannelWindow(_ChatWindow):
-
-    def cmd__join(self, channel='') -> None:
-        super().cmd__join(channel if channel else self.chatname)
-
-    def cmd__part(self) -> None:
-        'Attempt parting channel.'
-        self._send_msg('PART', (self.chatname,))
-
-
-class _QueryWindow(_ChatWindow):
-    pass
-
-
-class _Update:
-    old_value: Any
-    results: list[tuple[_LogScope, Any]]
-
-    def __init__(self, path: tuple[str, ...], value: Any) -> None:
-        self.full_path = path
-        self.rel_path = self.full_path[:]
-        self.value = value
-        self.old_value = None
-        self.force_log = False
-        self.results = []
-
-    @property
-    def key(self) -> str:
-        'Name of item or attribute to be processed.'
-        return self.rel_path[0]
-
-    def decrement_path(self) -> None:
-        'Remove first element from .rel_path.'
-        self.rel_path = self.rel_path[1:]
-
-
-class _UpdatingNode(AutoAttrMixin):
-
-    def _make_attr(self, cls: Callable, key: str):
-        return cls()
-
-    def recursive_set_and_report_change(self, update: _Update) -> None:
-        'Apply update, and, if it makes a difference, add to its .results.'
-        update.force_log = update.force_log or (not self._is_set(update.key))
-        node = self._get(update.key)
-        if len(update.rel_path) > 1:
-            update.decrement_path()
-            node.recursive_set_and_report_change(update)
-            return
-        update.old_value = node
-        do_report = update.force_log
-        if update.value is None:
-            if self._is_set(update.key):
-                self._unset(update.key)
-                do_report |= True
-        elif update.old_value != update.value:
-            self._set(update.key, update.value)
-            do_report |= True
-        if (not do_report) or update.full_path == ('message',):
-            return
-        result = (tuple(sorted(update.value)) if isinstance(update.value, set)
-                  else update.value)
-        announcement = ':' + ':'.join(update.full_path) + ' '
-        if result is None:
-            announcement += 'cleared'
-        else:
-            announcement += 'set to:'
-            if not isinstance(result, tuple):
-                announcement += f' [{result}]'
-        scope = _LogScope.DEBUG
-        update.results += [(scope, [announcement])]
-        if isinstance(result, tuple):
-            update.results += [(scope, [f':  {item}']) for item in result]
-
-    def _get(self, key: str) -> Any:
-        return getattr(self, key)
-
-    def _set(self, key: str, value) -> None:
-        setattr(self, key, value)
-
-    def _unset(self, key: str) -> None:
-        getattr(self, key).clear()
-
-    def _is_set(self, key: str) -> bool:
-        return hasattr(self, key)
-
-
-class _UpdatingDict(Dict[DictItem], _UpdatingNode):
-
-    def items(self) -> tuple[tuple[str, DictItem], ...]:
-        'Key-value pairs of item registrations.'
-        return tuple((k, v) for k, v in self._dict.items())
-
-    def _get(self, key: str):
-        if key not in self._dict:
-            self._dict[key] = self._item_cls()
-        return self._dict[key]
-
-    def _set(self, key: str, value) -> None:
-        self._dict[key] = value
-
-    def _unset(self, key: str) -> None:
-        del self._dict[key]
-
-    def _is_set(self, key: str) -> bool:
-        return key in self._dict
-
-
-class _UpdatingChannel(_UpdatingNode, Channel):
-    user_ids: set[str]
-    exits: _UpdatingDict[str]
-
-    def recursive_set_and_report_change(self, update: _Update) -> None:
-        super().recursive_set_and_report_change(update)
-        if update.key == 'topic':
-            msg = f':{self.topic.who} set topic: {self.topic.what}'
-            update.results += [(_LogScope.CHAT, [msg])]
-        elif update.key == 'user_ids':
-            if not update.old_value:
-                nicks = []
-                for id_ in sorted(update.value):
-                    nicks += [f'NICK:{id_}', ':, ']
-                nicks.pop()
-                update.results += [(_LogScope.CHAT, [':residents: '] + nicks)]
-            else:
-                for id_ in (id_ for id_ in update.value
-                            if id_ not in update.old_value):
-                    update.results += [(_LogScope.CHAT,
-                                        [f'NUH:{id_}', ': joins'])]
-                for id_ in (id_ for id_ in update.old_value
-                            if id_ not in update.value):
-                    update.results += [(_LogScope.CHAT,
-                                        _UpdatingUser.exit_msg_toks(
-                                            f'NUH:{id_}', self.exits[id_]))]
-
-
-class _UpdatingUser(_UpdatingNode, User):
-    prev_nick = '?'
-
-    @staticmethod
-    def exit_msg_toks(tok_who: str, exit_code: str) -> list[str]:
-        'Construct part/quit message from user identifier, exit_code.'
-        verb = 'quits' if exit_code[0] == 'Q' else 'parts'
-        exit_msg = exit_code[1:]
-        msg_toks = [tok_who, f': {verb}']
-        if exit_msg:
-            msg_toks += [f':: {exit_msg}']
-        return msg_toks
-
-    def recursive_set_and_report_change(self, update: _Update) -> None:
-        super().recursive_set_and_report_change(update)
-        if update.key in {'nick', 'exit_msg'}:
-            if update.key == 'nick':
-                self.prev_nick = update.old_value
-                if update.old_value != '?':
-                    update.results += [
-                        (_LogScope.USER,
-                         [f':{self.prev} renames {update.value}'])]
-            elif update.key == 'exit_msg':
-                if update.value:
-                    update.results += [(_LogScope.USER_NO_CHANNELS,
-                                        self.exit_msg_toks(
-                                            f':{self}', update.value))]
-
-    @property
-    def prev(self) -> str:
-        'Return .nickuserhost with .prev_nick as .nick.'
-        return str(NickUserHost(self.prev_nick, self.user, self.host))
-
-
-class _UpdatingServerCapability(_UpdatingNode, ServerCapability):
-    pass
-
-
-class _TuiClientDb(_UpdatingNode, SharedClientDbFields):
-    caps: _UpdatingDict[_UpdatingServerCapability]
-    isupport: _UpdatingDict[str]
-    motd: tuple[str, ...] = tuple()
-    users: _UpdatingDict[_UpdatingUser]
-    channels: _UpdatingDict[_UpdatingChannel]
-
-    def recursive_set_and_report_change(self, update: _Update) -> None:
-        super().recursive_set_and_report_change(update)
-        if update.key == 'connection_state':
-            if update.value == 'connected':
-                update.results += [(_LogScope.ALL, [':CONNECTED'])]
-            elif not update.value:
-                update.results += [(_LogScope.ALL, [':DISCONNECTED'])]
-        elif update.key == 'message' and update.value:
-            assert isinstance(update.value, ChatMessage)
-            toks = [':*** '] if update.value.is_notice else []
-            toks += [':[']
-            toks += [f':{update.value.sender}' if update.value.sender
-                     else 'NICK:me']
-            toks += [f':] {update.value.content}']
-            update.results += [(_LogScope.CHAT, toks)]
-
-
-class _ClientWindowsManager:
-
-    def __init__(self, tui_log: Callable, tui_new_window: Callable) -> None:
-        self._tui_log = tui_log
-        self._tui_new_window = tui_new_window
-        self.db = _TuiClientDb()
-        self.windows: list[_ClientWindow] = []
-
-    def _new_win(self, scope: _LogScope, chatname: str = '') -> _ClientWindow:
-        if scope == _LogScope.CHAT:
-            win = self._tui_new_window(
-                    win_cls=(_ChannelWindow if self.db.is_chan_name(chatname)
-                             else _QueryWindow),
-                    chatname=chatname,
-                    get_nick_data=lambda: (self.db.users['me'].nick
-                                           if 'me' in self.db.users.keys()
-                                           else '?'))
-        else:
-            win = self._tui_new_window(win_cls=_ClientWindow)
-        self.windows += [win]
-        return win
-
-    def windows_for(self, scope: _LogScope, id_='') -> list[_ClientWindow]:
-        'Return client windows of scope, and additional potential identifier.'
-        ret = []
-        if scope == _LogScope.ALL:
-            ret = [w for w in self.windows
-                   if w not in self.windows_for(_LogScope.DEBUG)]
-        elif scope == _LogScope.DEBUG:
-            ret = [w for w in self.windows if not isinstance(w, _ChatWindow)]
-        elif scope == _LogScope.CHAT:
-            ret = [w for w in self.windows
-                   if isinstance(w, _ChatWindow) and w.chatname == id_]
-        elif scope == _LogScope.USER:
-            chan_names = [c for c, v in self.db.channels.items()
-                          if id_ in v.user_ids]
-            ret = [w for w in self.windows
-                   if (isinstance(w, _ChannelWindow)
-                       and w.chatname in chan_names)
-                   or (isinstance(w, _QueryWindow)
-                       and (id_ == 'me' or w.chatname in {
-                           self.db.users[id_].nick,
-                           self.db.users[id_].prev_nick}))]
-        elif scope == _LogScope.USER_NO_CHANNELS:
-            ret = [w for w in self.windows_for(_LogScope.USER, id_)
-                   if isinstance(w, _QueryWindow)]
-        if (not ret) and scope in {_LogScope.CHAT, _LogScope.DEBUG}:
-            ret += [self._new_win(scope, id_)]
-        ret.sort(key=lambda w: w.idx)
-        return ret
-
-    def log(self,
-            msg: str,
-            scope: _LogScope,
-            alert=False,
-            target='',
-            out: Optional[bool] = None
-            ) -> None:
-        'From parsing scope, kwargs, build prefix before sending to logger.'
-        prefix = '$'
-        if out is not None:
-            prefix = _LOG_PREFIX_OUT if out else _LOG_PREFIX_IN
-        kwargs = {'alert': True} if alert else {}
-        kwargs |= {'target': target} if target else {}
-        self._tui_log(msg, scope=scope, prefix=prefix, **kwargs)
-
-    def update_db(self, update: _Update) -> bool:
-        'Apply update to .db, and if changing anything, log and trigger.'
-        self.db.recursive_set_and_report_change(update)
-        if not update.results:
-            return False
-        for scope, result in update.results:
-            msg = ''
-            for item in result:
-                transform, content = item.split(':', maxsplit=1)
-                if transform in {'NICK', 'NUH'}:
-                    nuh = self.db.users[content]
-                    content = str(nuh) if transform == 'NUH' else nuh.nick
-                msg += content
-            out: Optional[bool] = None
-            target = ''
-            if update.full_path == ('message',):
-                target = update.value.target or update.value.sender
-                out = not bool(update.value.sender)
-            elif scope in {_LogScope.CHAT, _LogScope.USER,
-                           _LogScope.USER_NO_CHANNELS}:
-                target = update.full_path[1]
-            self.log(msg, scope=scope, target=target, out=out)
-        for win in [w for w in self.windows if isinstance(w, _ChatWindow)]:
-            win.set_prompt_prefix()
-        return bool([w for w in self.windows if w.tainted])
-
-
-class ClientTui(BaseTui):
-    'TUI expanded towards Client features.'
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._client_mngrs: dict[str, _ClientWindowsManager] = {}
-
-    def _log_target_wins(self, **kwargs) -> Sequence[Window]:
-        if (scope := kwargs.get('scope', None)):
-            return self._client_mngrs[kwargs['client_id']].windows_for(
-                    scope, kwargs.get('target', ''))
-        return super()._log_target_wins(**kwargs)
-
-    def for_client_do(self, client_id: str, todo: str, **kwargs) -> None:
-        'Forward todo to appropriate _ClientWindowsManager.'
-        if client_id not in self._client_mngrs:
-            self._client_mngrs[client_id] = _ClientWindowsManager(
-                tui_log=lambda msg, **kw: self.log(
-                    msg, client_id=client_id, **kw),
-                tui_new_window=lambda win_cls, **kw: self._new_window(
-                    win_cls, _q_out=self._q_out, client_id=client_id, **kw))
-        if getattr(self._client_mngrs[client_id], todo)(**kwargs) is not False:
-            self.redraw_affected()
-
-    def _new_client(self, conn_setup: IrcConnSetup) -> 'ClientKnowingTui':
-        return ClientKnowingTui(_q_out=self._q_out, conn_setup=conn_setup)
-
-    def cmd__connect(self,
-                     host_port: str,
-                     nickname_pw: str = '',
-                     username_realname: str = ''
-                     ) -> Optional[str]:
-        'Create Client and pass it via NewClientEvent.'
-        split = host_port.split(':', maxsplit=1)
-        hostname = split[0]
-        if hostname in self._client_mngrs:
-            return f'already set up connection to {hostname}'
-        port = -1
-        if len(split) > 1:
-            to_int = split[1]
-            if to_int.isdigit():
-                port = int(split[1])
-            else:
-                return f'invalid port number: {to_int}'
-        split = nickname_pw.split(':', maxsplit=1)
-        nickname = split[0] if nickname_pw else getuser()
-        password = split[1] if len(split) > 1 else ''
-        if not username_realname:
-            username = ''
-            realname = nickname
-        elif ':' in username_realname:
-            username, realname = username_realname.split(':', maxsplit=1)
-        else:
-            username = ''
-            realname = username_realname
-        self._put(NewClientEvent(self._new_client(IrcConnSetup(
-            hostname, port, nickname, username, realname, password))))
-        return None
-
-
-class ClientKnowingTui(Client):
-    'Adapted to communicate with ClientTui.'
-
-    def _tui_trigger(self, method_name: str, **kwargs) -> None:
-        self._put(TuiEvent.affector(method_name).kw(**kwargs))
-
-    def _tui_alert_trigger(self, msg: str) -> None:
-        self._tui_trigger('log', msg=msg, prefix=_LOG_PREFIX_SERVER,
-                          alert=True)
-
-    def _client_tui_trigger(self, todo: str, **kwargs) -> None:
-        self._tui_trigger('for_client_do', client_id=self.client_id,
-                          todo=todo, **kwargs)
-
-    def send_w_params_tuple(self, verb: str, params: tuple[str, ...]) -> None:
-        'Helper for ClientWindow to trigger .send, for it can only do kwargs.'
-        self.send(verb, *params)
-
-    def privmsg(self, chat_target: str, msg: str) -> None:
-        'Catch /privmsg, only allow for channel if in channel, else complain.'
-        try:
-            if self.db.is_chan_name(chat_target)\
-                    and chat_target not in self.db.channels.keys():
-                raise SendFail('not sending, since not in channel')
-            self.send('PRIVMSG', chat_target, msg)
-        except SendFail as e:
-            self._tui_alert_trigger(f'{e}')
-        else:
-            self.db.messaging('').to(chat_target).privmsg = msg  # type: ignore
-
-    def reconnect(self) -> None:
-        'Catch /reconnect, only initiate if not connected, else complain back.'
-        if self.conn:
-            self._tui_alert_trigger(
-                    'not re-connecting since already connected')
-            return
-        self.connect()
-
-    def send(self, verb: str, *args) -> IrcMessage:
-        msg = super().send(verb, *args)
-        self._log(msg.raw, out=True)
-        return msg
-
-    def handle_msg(self, msg: IrcMessage) -> None:
-        self._log(msg.raw, out=False)
-        try:
-            super().handle_msg(msg)
-        except ImplementationFail as e:
-            self._alert(str(e))
-        except TargetUserOffline as e:
-            name = f'{e}'
-            self._log(f'{name} not online', target=name, alert=True)
-
-    def _alert(self, msg: str) -> None:
-        self._log(msg, alert=True)
-
-    def _log(self, msg: str, alert=False, target='', out: Optional[bool] = None
-             ) -> None:
-        scope = _LogScope.CHAT if target else _LogScope.DEBUG
-        self._client_tui_trigger('log', scope=scope, msg=msg, alert=alert,
-                                 target=target, out=out)
-
-    def _on_update(self, *path) -> None:
-        for path, value in self.db.into_endnode_updates(path):
-            self._client_tui_trigger('update_db', update=_Update(path, value))
diff --git a/ircplom/events.py b/ircplom/events.py
deleted file mode 100644 (file)
index 9dac06b..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-'Event system with event loop.'
-from abc import abstractmethod, ABC
-from dataclasses import dataclass
-from queue import SimpleQueue, Empty as QueueEmpty
-from threading import Thread
-from typing import Any, Iterator, Literal, Self
-
-
-class Event:  # pylint: disable=too-few-public-methods
-    'Communication unit between threads.'
-
-
-QuitEvent = type('QuitEvent', (Event,), {})
-
-
-class AffectiveEvent(Event, ABC):
-    'For Events that are to affect other objects.'
-
-    @abstractmethod
-    def affect(self, target: Any) -> None:
-        'To be run by main loop on target.'
-
-    @classmethod
-    def affector(cls, t_method: str, **kwargs):
-        '''Return instance of subclass affecting target via t_method.
-
-        This will often be more convenient than a full subclass definition,
-        which mostly only matters for what the main loop gotta differentiate.
-        '''
-        def wrap():  # else current mypy throw [valid-type] on 'Variable "cls"'
-            class _Affector(cls):
-                def __init__(self, t_method: str, **kwargs) -> None:
-                    super().__init__(**kwargs)
-                    self.t_method = t_method
-                    self.kwargs: dict[str, Any] = {}
-
-                def kw(self, **kwargs) -> Self:
-                    'Collect .kwargs expanded, return self for chaining.'
-                    for k, v in kwargs.items():
-                        self.kwargs[k] = v
-                    return self
-
-                def affect(self, target) -> None:
-                    'Call target.t_method(**.kwargs).'
-                    getattr(target, self.t_method)(**self.kwargs)
-
-            return _Affector
-
-        return wrap()(t_method=t_method, **kwargs)
-
-
-class CrashingException(BaseException):
-    'To explicitly crash, because it should never happen, but explaining why.'
-
-
-@dataclass
-class ExceptionEvent(Event):
-    'To deliver Exception to main loop for handling.'
-    exception: CrashingException
-
-
-@dataclass
-class QueueMixin:
-    'Adds SimpleQueue addressable via ._put(Event).'
-    _q_out: SimpleQueue
-
-    def _put(self, event: Event) -> None:
-        self._q_out.put(event)
-
-
-class Loop(QueueMixin):
-    'Wraps thread looping over iterator, communicating back via q_out.'
-
-    def __init__(self, iterator: Iterator, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._q_quit: SimpleQueue = SimpleQueue()
-        self._iterator = iterator
-        self._thread = Thread(target=self._loop, daemon=False)
-        self._thread.start()
-
-    def stop(self) -> None:
-        'Break threaded loop, but wait for it to finish properly.'
-        self._q_quit.put(None)
-        self._thread.join()
-
-    def __enter__(self) -> Self:
-        return self
-
-    def __exit__(self, *_) -> Literal[False]:
-        self.stop()
-        return False  # re-raise any exception that above ignored
-
-    def _loop(self) -> None:
-        try:
-            while True:
-                try:
-                    self._q_quit.get(block=True, timeout=0)
-                except QueueEmpty:
-                    pass
-                else:
-                    break
-                try:
-                    it_yield = next(self._iterator)
-                except StopIteration:
-                    break
-                if it_yield is not None:
-                    self._put(it_yield)
-        # catch _all_ just so they exit readably with the main loop
-        except Exception as e:  # pylint: disable=broad-exception-caught
-            self._put(ExceptionEvent(CrashingException(e)))
diff --git a/ircplom/irc_conn.py b/ircplom/irc_conn.py
deleted file mode 100644 (file)
index fb1bd21..0000000
+++ /dev/null
@@ -1,207 +0,0 @@
-'Low-level IRC protocol / server connection management.'
-# built-ins
-from abc import ABC, abstractmethod
-from socket import socket, gaierror as socket_gaierror
-from ssl import create_default_context as create_ssl_context
-from typing import Callable, Iterator, NamedTuple, Optional, Self
-# ourselves
-from ircplom.events import Event, Loop, QueueMixin
-
-
-PORT_SSL = 6697
-_TIMEOUT_RECV_LOOP = 0.1
-_TIMEOUT_CONNECT = 5
-_CONN_RECV_BUFSIZE = 1024
-
-ILLEGAL_NICK_CHARS = ' ,*?!@'
-ILLEGAL_NICK_FIRSTCHARS = ':$'
-ISUPPORT_DEFAULTS = {
-    'CHANTYPES': '#&',
-    'PREFIX': '(ov)@+',
-    'USERLEN': '10'
-}
-_IRCSPEC_LINE_SEPARATOR = b'\r\n'
-_IRCSPEC_TAG_ESCAPES = ((r'\:', ';'),
-                        (r'\s', ' '),
-                        (r'\n', '\n'),
-                        (r'\r', '\r'),
-                        (r'\\', '\\'))
-
-
-class IrcMessage:
-    'Properly structured representation of IRC message as per IRCv3 spec.'
-    _raw: Optional[str] = None
-
-    def __init__(self,
-                 verb: str,
-                 params: Optional[tuple[str, ...]] = None,
-                 source: str = '',
-                 tags: Optional[dict[str, str]] = None
-                 ) -> None:
-        self.verb: str = verb
-        self.params: tuple[str, ...] = params or tuple()
-        self.source: str = source
-        self.tags: dict[str, str] = tags or {}
-
-    @classmethod
-    def from_raw(cls, raw_msg: str) -> Self:
-        'Parse raw IRC message line into properly structured IrcMessage.'
-
-        class _Stage(NamedTuple):
-            name: str
-            prefix_char: Optional[str]
-            processor: Callable = lambda s: s
-
-        def _parse_tags(str_tags: str) -> dict[str, str]:
-            tags = {}
-            for str_tag in [s for s in str_tags.split(';') if s]:
-                if '=' in str_tag:
-                    key, val = str_tag.split('=', maxsplit=1)
-                    for to_repl, repl_with in _IRCSPEC_TAG_ESCAPES:
-                        val = val.replace(to_repl, repl_with)
-                else:
-                    key, val = str_tag, ''
-                tags[key] = val
-            return tags
-
-        def _split_params(str_params: str) -> tuple[str, ...]:
-            params = []
-            params_stage = 0  # 0: gap, 1: non-trailing, 2: trailing
-            for char in str_params:
-                if char == ' ' and params_stage < 2:
-                    params_stage = 0
-                    continue
-                if params_stage == 0:
-                    params += ['']
-                    params_stage += 1
-                    if char == ':':
-                        params_stage += 1
-                        continue
-                params[-1] += char
-            return tuple(p for p in params)
-
-        stages = [_Stage('tags', '@', _parse_tags),
-                  _Stage('source', ':'),
-                  _Stage('verb', None, lambda s: s.upper()),
-                  _Stage('params', None, _split_params)]
-        harvest = {s.name: '' for s in stages}
-        idx_stage = -1
-        stage = None
-        for char in raw_msg:
-            if char == ' ' and idx_stage < (len(stages) - 1):
-                if stage:
-                    stage = None
-                continue
-            if not stage:
-                while not stage:
-                    idx_stage += 1
-                    tested = stages[idx_stage]
-                    if (not tested.prefix_char) or char == tested.prefix_char:
-                        stage = tested
-                if stage.prefix_char:
-                    continue
-            harvest[stage.name] += char
-        msg = cls(**{s.name: s.processor(harvest[s.name]) for s in stages})
-        msg._raw = raw_msg
-        return msg
-
-    @property
-    def raw(self) -> str:
-        'Return raw message code – create from known fields if necessary.'
-        if not self._raw:
-            to_combine = []
-            if self.tags:
-                tag_strs = []
-                for key, val in self.tags.items():
-                    tag_strs += [key]
-                    if not val:
-                        continue
-                    for repl_with, to_repl in reversed(_IRCSPEC_TAG_ESCAPES):
-                        val = val.replace(to_repl, repl_with)
-                    tag_strs[-1] += f'={val}'
-                to_combine += ['@' + ';'.join(tag_strs)]
-            to_combine += [self.verb]
-            if self.params:
-                to_combine += self.params[:-1]
-                to_combine += [f':{self.params[-1]}']
-            self._raw = ' '.join(to_combine)
-        return self._raw
-
-
-class IrcConnAbortException(BaseException):
-    'Thrown by BaseIrcConnection on expectable connection failures.'
-
-
-class BaseIrcConnection(QueueMixin, ABC):
-    'Collects low-level server-client connection management.'
-
-    def __init__(self, hostname: str, port: int, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self.ssl = port == PORT_SSL
-        self._set_up_socket(hostname, port)
-        self._recv_loop = Loop(iterator=self._read_lines(), _q_out=self._q_out)
-
-    def _set_up_socket(self, hostname: str, port: int) -> None:
-        self._socket = socket()
-        if self.ssl:
-            self._socket = create_ssl_context().wrap_socket(
-                self._socket, server_hostname=hostname)
-        self._socket.settimeout(_TIMEOUT_CONNECT)
-        try:
-            self._socket.connect((hostname, port))
-        except (TimeoutError, socket_gaierror) as e:
-            raise IrcConnAbortException(e) from e
-        self._socket.settimeout(_TIMEOUT_RECV_LOOP)
-
-    def close(self) -> None:
-        'Stop recv loop and close socket.'
-        self._recv_loop.stop()
-        self._socket.close()
-
-    def send(self, msg: IrcMessage) -> None:
-        'Send line-separator-delimited message over socket.'
-        self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR)
-
-    @abstractmethod
-    def _make_recv_event(self, msg: IrcMessage) -> Event:
-        pass
-
-    @abstractmethod
-    def _on_handled_loop_exception(self, _: IrcConnAbortException) -> Event:
-        pass
-
-    def _read_lines(self) -> Iterator[Optional[Event]]:
-        assert self._socket is not None
-        bytes_total = b''
-        buffer_linesep = b''
-        try:
-            while True:
-                try:
-                    bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE)
-                except TimeoutError:
-                    yield None
-                    continue
-                except ConnectionResetError as e:
-                    raise IrcConnAbortException(e) from e
-                except OSError as e:
-                    if e.errno == 9:
-                        raise IrcConnAbortException(e) from e
-                    raise e
-                if not bytes_new:
-                    break
-                for c in bytes_new:
-                    c_byted = c.to_bytes()
-                    if c not in _IRCSPEC_LINE_SEPARATOR:
-                        bytes_total += c_byted
-                        buffer_linesep = b''
-                    elif c == _IRCSPEC_LINE_SEPARATOR[0]:
-                        buffer_linesep = c_byted
-                    else:
-                        buffer_linesep += c_byted
-                    if buffer_linesep == _IRCSPEC_LINE_SEPARATOR:
-                        buffer_linesep = b''
-                        yield self._make_recv_event(
-                            IrcMessage.from_raw(bytes_total.decode('utf-8')))
-                        bytes_total = b''
-        except IrcConnAbortException as e:
-            yield self._on_handled_loop_exception(e)
diff --git a/ircplom/msg_parse_expectations.py b/ircplom/msg_parse_expectations.py
deleted file mode 100644 (file)
index 84478b4..0000000
+++ /dev/null
@@ -1,560 +0,0 @@
-'Structured expectations and processing hints for server messages.'
-from enum import Enum, auto
-from typing import Any, Callable, NamedTuple, Optional, Self
-from ircplom.irc_conn import IrcMessage
-
-
-class _MsgTok(Enum):
-    'Server message token classifications.'
-    ANY = auto()
-    CHANNEL = auto()
-    LIST = auto()
-    NICKNAME = auto()
-    NONE = auto()
-    SERVER = auto()
-    NICK_USER_HOST = auto()
-
-
-_MsgTokGuide = str | _MsgTok | tuple[str | _MsgTok, str]
-
-
-class _Command(NamedTuple):
-    verb: str
-    path: tuple[str, ...]
-
-    @classmethod
-    def from_(cls, input_: str) -> Self:
-        'Split by first "_" into verb, path (split into steps tuple by ".").'
-        verb, path_str = input_.split('_', maxsplit=1)
-        return cls(verb, tuple(step for step in path_str.split('.')
-                               if path_str))
-
-
-class _MsgParseExpectation:
-
-    def __init__(self,
-                 verb: str,
-                 source: _MsgTokGuide,
-                 params: tuple[_MsgTokGuide, ...] = tuple(),
-                 idx_into_list: int = -1,
-                 bonus_tasks: tuple[str, ...] = tuple()
-                 ) -> None:
-
-        class _Code(NamedTuple):
-            title: str
-            commands: tuple[_Command, ...]
-
-            @classmethod
-            def from_(cls, input_: str) -> Self:
-                'Split by ":" into commands (further split by ","), title.'
-                cmdsstr, title = input_.split(':', maxsplit=1)
-                return cls(title, tuple(_Command.from_(t)
-                                        for t in cmdsstr.split(',') if t))
-
-        class _TokExpectation(NamedTuple):
-            type_: _MsgTok | str
-            code: Optional[_Code]
-
-            @classmethod
-            def from_(cls, val: _MsgTokGuide) -> Self:
-                'Standardize value into .type_, (potentially empty) .code.'
-                t = ((val[0], _Code.from_(val[1])) if isinstance(val, tuple)
-                     else (val, None))
-                return cls(*t)
-
-        self.verb = verb
-        self.source = _TokExpectation.from_(source)
-        self.params = tuple(_TokExpectation.from_(param) for param in params)
-        self.idx_into_list = idx_into_list
-        self.bonus_tasks = tuple(_Code.from_(item) for item in bonus_tasks)
-
-    def parse_msg(self,
-                  msg: IrcMessage,
-                  is_chan_name: Callable,
-                  is_nick: Callable,
-                  possible_nickuserhost: Callable,
-                  into_nickuserhost: Callable
-                  ) -> Optional[dict[str, Any]]:
-        'Try parsing msg into informative result dictionary, or None on fail.'
-        cmp_params: list[str | tuple[str, ...]]
-        if self.idx_into_list < 0:
-            cmp_params = list(msg.params)
-        else:
-            idx_after = len(msg.params) + 1 - (len(self.params)
-                                               - self.idx_into_list)
-            cmp_params = (list(msg.params[:self.idx_into_list]) +
-                          [msg.params[self.idx_into_list:idx_after]] +
-                          list(msg.params[idx_after:]))
-        cmp_fields = tuple([msg.source] + cmp_params)
-        ex_fields = tuple([self.source] + list(self.params))
-        if len(ex_fields) != len(cmp_fields):
-            return None
-        validators: dict[_MsgTok, Callable[[Any], bool]] = {
-            _MsgTok.NONE: lambda tok: tok == '',
-            _MsgTok.CHANNEL: is_chan_name,
-            _MsgTok.NICKNAME: is_nick,
-            _MsgTok.NICK_USER_HOST: possible_nickuserhost,
-            _MsgTok.SERVER: lambda tok: '.' in tok and not set('@!') & set(tok)
-        }
-        parsers: dict[_MsgTok, Callable[[Any], Any]] = {
-            _MsgTok.LIST: lambda tok: tuple(tok.split()),
-            _MsgTok.NICK_USER_HOST: into_nickuserhost
-        }
-        parsed: dict[str, str | tuple[str, ...]] = {}
-        singled_tasks: list[tuple[_Command, str]] = []
-        nickuserhosts = []
-        for ex_tok, cmp_tok in [(ex_tok, cmp_fields[idx])
-                                for idx, ex_tok in enumerate(ex_fields)]:
-            if isinstance(ex_tok.type_, str) and ex_tok.type_ != cmp_tok:
-                return None
-            if (not isinstance(ex_tok.type_, str))\
-                    and ex_tok.type_ in validators\
-                    and not validators[ex_tok.type_](cmp_tok):
-                return None
-            if ex_tok.code or ex_tok.type_ is _MsgTok.NICK_USER_HOST:
-                value = (cmp_tok if (isinstance(ex_tok.type_, str)
-                                     or ex_tok.type_ not in parsers)
-                         else parsers[ex_tok.type_](cmp_tok))
-                if ex_tok.type_ is _MsgTok.NICK_USER_HOST:
-                    nickuserhosts += [value]
-                if ex_tok.code:
-                    parsed[ex_tok.code.title] = value
-                    singled_tasks += [(cmd, ex_tok.code.title)
-                                      for cmd in ex_tok.code.commands]
-        for code in self.bonus_tasks:
-            singled_tasks += [(cmd, code.title) for cmd in code.commands]
-        tasks: dict[_Command, list[str]] = {}
-        for cmd, title in singled_tasks:
-            if cmd not in tasks:
-                tasks[cmd] = []
-            tasks[cmd] += [title]
-        return parsed | {'_verb': self.verb, '_tasks': tasks,
-                         '_nickuserhosts': nickuserhosts}
-
-
-MSG_EXPECTATIONS: list[_MsgParseExpectation] = [
-
-    # these we ignore except for confirming/collecting the nickname
-
-    _MsgParseExpectation(
-        '001',  # RPL_WELCOME
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '002',  # RPL_YOURHOST
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '003',  # RPL_CREATED
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '004',  # RPL_MYINFO
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY,
-         _MsgTok.ANY,
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '250',  # RPL_STATSDLINE / RPL_STATSCONN
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '251',  # RPL_LUSERCLIENT
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '252',  # RPL_LUSEROP
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '253',  # RPL_LUSERUNKNOWN
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '254',  # RPL_LUSERCHANNELS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '255',  # RPL_LUSERME
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '265',  # RPL_LOCALUSERS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-    _MsgParseExpectation(
-        '265',  # RPL_LOCALUSERS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '266',  # RPL_GLOBALUSERS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-    _MsgParseExpectation(
-        '266',  # RPL_GLOBALUSERS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,
-         _MsgTok.ANY,
-         _MsgTok.ANY)),
-
-    _MsgParseExpectation(
-        '375',  # RPL_MOTDSTART already implied by 1st 372
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY)),
-
-    # various login stuff
-
-    _MsgParseExpectation(
-        '005',  # RPL_ISUPPORT
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, ':isupport'),
-         _MsgTok.ANY),  # comment
-        idx_into_list=1),
-
-    _MsgParseExpectation(
-        '372',  # RPL_MOTD
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, ':line'))),
-
-    _MsgParseExpectation(
-        '376',  # RPL_ENDOFMOTD
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY),  # comment
-        bonus_tasks=('do_db.motd:complete',)),
-
-    _MsgParseExpectation(
-        '396',  # RPL_VISIBLEHOST
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.SERVER, 'setattr_db.users.me:host'),
-         _MsgTok.ANY)),  # comment
-
-    # SASL
-
-    _MsgParseExpectation(
-        '900',  # RPL_LOGGEDIN
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
-         (_MsgTok.ANY, 'setattr_db:sasl_account'),
-         _MsgTok.ANY)),  # comment
-
-    _MsgParseExpectation(
-        '903',  # RPL_SASLSUCCESS
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
-        bonus_tasks=('do_caps:end_negotiation',)),
-
-    _MsgParseExpectation(
-        '904',  # ERR_SASLFAIL
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
-        bonus_tasks=('do_caps:end_negotiation',)),
-
-    _MsgParseExpectation(
-        'AUTHENTICATE',
-        _MsgTok.NONE,
-        ('+',)),
-
-    # capability negotation
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('NEW', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('DEL', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('ACK', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('ACK', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('NAK', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('NAK', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('LS', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('LS', ':subverb'),
-         ('*', ':tbc'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('LS', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('LS', ':subverb'),
-         ('*', ':tbc'),
-         (_MsgTok.LIST, ':items'))),
-
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('LIST', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ('*',
-         ('LIST', ':subverb'),
-         ('*', ':tbc'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('LIST', ':subverb'),
-         (_MsgTok.LIST, ':items'))),
-    _MsgParseExpectation(
-        'CAP',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         ('LIST', ':subverb'),
-         ('*', ':tbc'),
-         (_MsgTok.LIST, ':items'))),
-
-    # nickname management
-
-    _MsgParseExpectation(
-        '432',  # ERR_ERRONEOUSNICKNAME
-        _MsgTok.SERVER,
-        ('*',
-         _MsgTok.ANY,  # bad one probably fails our NICKNAME tests
-         _MsgTok.ANY)),  # comment
-    _MsgParseExpectation(
-        '432',  # ERR_ERRONEOUSNICKNAME
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         _MsgTok.ANY,  # bad one probably fails our NICKNAME tests
-         _MsgTok.ANY)),  # comment
-
-    _MsgParseExpectation(
-        '433',  # ERR_NICKNAMEINUSE
-        _MsgTok.SERVER,
-        ('*',
-         (_MsgTok.NICKNAME, ':used'),
-         _MsgTok.ANY)),  # comment
-    _MsgParseExpectation(
-        '433',  # ERR_NICKNAMEINUSE
-        _MsgTok.SERVER,
-        (_MsgTok.NICKNAME,  # we rather go for incrementation
-         (_MsgTok.NICKNAME, ':used'),
-         _MsgTok.ANY)),  # comment
-
-    _MsgParseExpectation(
-        'NICK',
-        (_MsgTok.NICK_USER_HOST, ':named'),
-        ((_MsgTok.NICKNAME, ':nick'),)),
-
-    # joining/leaving
-
-    _MsgParseExpectation(
-        '332',  # RPL_TOPIC
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.CHANNEL, ':CHAN'),
-         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
-
-    _MsgParseExpectation(
-        '333',  # RPL_TOPICWHOTIME
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.CHANNEL, ':CHAN'),
-         (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
-         (_MsgTok.ANY, ':timestamp')),
-        bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)),
-
-    _MsgParseExpectation(
-        '353',  # RPL_NAMREPLY
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         '@',
-         (_MsgTok.CHANNEL, ':channel'),
-         (_MsgTok.LIST, ':names'))),
-    _MsgParseExpectation(
-        '353',  # RPL_NAMREPLY
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         '=',
-         (_MsgTok.CHANNEL, ':channel'),
-         (_MsgTok.LIST, ':names'))),
-
-    _MsgParseExpectation(
-        '366',  # RPL_ENDOFNAMES
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.CHANNEL, ':CHAN'),
-         _MsgTok.ANY),  # comment
-        bonus_tasks=('doafter_db.channels.CHAN.user_ids:complete',)),
-
-    _MsgParseExpectation(
-        'JOIN',
-        (_MsgTok.NICK_USER_HOST, ':joiner'),
-        ((_MsgTok.CHANNEL, ':channel'),)),
-
-    _MsgParseExpectation(
-        'PART',
-        (_MsgTok.NICK_USER_HOST, ':parter'),
-        ((_MsgTok.CHANNEL, ':channel'),)),
-    _MsgParseExpectation(
-        'PART',
-        (_MsgTok.NICK_USER_HOST, ':parter'),
-        ((_MsgTok.CHANNEL, ':channel'),
-         (_MsgTok.ANY, ':message'))),
-
-    # messaging
-
-    _MsgParseExpectation(
-        '401',  # ERR_NOSUCKNICK
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.NICKNAME, ':missing'),
-         _MsgTok.ANY)),  # comment
-
-    _MsgParseExpectation(
-        'NOTICE',
-        _MsgTok.SERVER,
-        ('*',
-         (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))),
-    _MsgParseExpectation(
-        'NOTICE',
-        _MsgTok.SERVER,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))),
-
-    _MsgParseExpectation(
-        'NOTICE',
-        (_MsgTok.NICK_USER_HOST, ':USER'),
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:notice'))),
-
-    _MsgParseExpectation(
-        'NOTICE',
-        (_MsgTok.NICK_USER_HOST, ':USER'),
-        ((_MsgTok.CHANNEL, ':CHANNEL'),
-         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:notice'))),
-
-    _MsgParseExpectation(
-        'PRIVMSG',
-        (_MsgTok.NICK_USER_HOST, ':USER'),
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:privmsg'))),
-    _MsgParseExpectation(
-        'PRIVMSG',
-        (_MsgTok.NICK_USER_HOST, ':USER'),
-        ((_MsgTok.CHANNEL, ':CHANNEL'),
-         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))),
-
-    # misc.
-
-    _MsgParseExpectation(
-        'ERROR',
-        _MsgTok.NONE,
-        ((_MsgTok.ANY, 'setattr_db:connection_state'),),
-        bonus_tasks=('doafter_:close',)),
-
-    _MsgParseExpectation(
-        'MODE',
-        (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db.users.me:modes'))),
-    _MsgParseExpectation(
-        'MODE',
-        _MsgTok.NICKNAME,
-        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
-         (_MsgTok.ANY, 'setattr_db.users.me:modes'))),
-
-    _MsgParseExpectation(
-        'PING',
-        _MsgTok.NONE,
-        ((_MsgTok.ANY, ':reply'),)),
-
-    _MsgParseExpectation(
-        'TOPIC',
-        (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
-        ((_MsgTok.CHANNEL, ':CHAN'),
-         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what')),
-        bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)),
-
-    _MsgParseExpectation(
-        'QUIT',
-        (_MsgTok.NICK_USER_HOST, ':quitter'),
-        ((_MsgTok.ANY, ':message'),)),
-]
diff --git a/ircplom/testing.py b/ircplom/testing.py
deleted file mode 100644 (file)
index 57de470..0000000
+++ /dev/null
@@ -1,157 +0,0 @@
-'Basic testing.'
-from contextlib import contextmanager
-from queue import SimpleQueue, Empty as QueueEmpty
-from pathlib import Path
-from typing import Generator, Iterator, Optional
-from ircplom.events import Event, Loop, QueueMixin
-from ircplom.client import IrcConnection, IrcConnSetup
-from ircplom.client_tui import ClientKnowingTui, ClientTui
-from ircplom.irc_conn import IrcConnAbortException, IrcMessage
-from ircplom.tui_base import TerminalInterface, TuiEvent
-
-
-_PATH_TEST_TXT = Path('test.txt')
-
-
-class TestTerminal(QueueMixin, TerminalInterface):
-    'Collects keypresses from string queue, otherwise mostly dummy.'
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._q_keypresses: SimpleQueue = SimpleQueue()
-
-    @contextmanager
-    def setup(self) -> Generator:
-        with Loop(iterator=self._get_keypresses(), _q_out=self._q_out):
-            yield self
-
-    def flush(self) -> None:
-        pass
-
-    def calc_geometry(self) -> None:
-        self.size = TerminalInterface.__annotations__['size'](0, 0)
-
-    def wrap(self, line: str) -> list[str]:
-        return []
-
-    def write(self,
-              msg: str = '',
-              start_y: Optional[int] = None,
-              attribute: Optional[str] = None,
-              padding: bool = True
-              ) -> None:
-        pass
-
-    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
-        while True:
-            try:
-                to_yield = self._q_keypresses.get(timeout=0.1)
-            except QueueEmpty:
-                yield None
-                continue
-            yield TuiEvent.affector('handle_keyboard_event'
-                                    ).kw(typed_in=to_yield)
-
-
-class _FakeIrcConnection(IrcConnection):
-
-    def __init__(self, **kwargs) -> None:
-        self._q_server_msgs: SimpleQueue = SimpleQueue()
-        super().__init__(**kwargs)
-
-    def put_server_msg(self, msg: str) -> None:
-        'Simulate message coming from server.'
-        self._q_server_msgs.put(msg)
-
-    def _set_up_socket(self, hostname: str, port: int) -> None:
-        pass
-
-    def close(self) -> None:
-        self._recv_loop.stop()
-
-    def send(self, msg: IrcMessage) -> None:
-        pass
-
-    def _read_lines(self) -> Iterator[Optional[Event]]:
-        while True:
-            try:
-                msg = self._q_server_msgs.get(timeout=0.1)
-            except QueueEmpty:
-                yield None
-                continue
-            if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION':
-                err = IrcConnAbortException(msg)
-                yield self._on_handled_loop_exception(err)
-                return
-            yield self._make_recv_event(IrcMessage.from_raw(msg))
-
-
-class _TestClientKnowingTui(ClientKnowingTui):
-    _cls_conn = _FakeIrcConnection
-
-
-class TestingClientTui(ClientTui):
-    'Collects keypresses via TestTerminal and test file, compares log results.'
-    _clients: list[_TestClientKnowingTui]
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._clients = []
-        assert isinstance(self._term, TestTerminal)
-        self._q_keypresses = self._term._q_keypresses
-        with _PATH_TEST_TXT.open('r', encoding='utf8') as f:
-            self._playbook = tuple(line[:-1] for line in f.readlines())
-        self._playbook_idx = -1
-        self._play_till_next_log()
-
-    def _new_client(self, conn_setup: IrcConnSetup) -> _TestClientKnowingTui:
-        self._clients += [_TestClientKnowingTui(_q_out=self._q_out,
-                                                conn_setup=conn_setup)]
-        return self._clients[-1]
-
-    def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
-        win_ids, logged_msg = super().log(msg, **kwargs)
-        time_str, msg_sans_time = logged_msg.split(' ', maxsplit=1)
-        assert len(time_str) == 8
-        for c in time_str[:2] + time_str[3:5] + time_str[6:]:
-            assert c.isdigit()
-        assert time_str[2] == ':' and time_str[5] == ':'
-        context, expected_msg = self._playbook[self._playbook_idx
-                                               ].split(maxsplit=1)
-        if ':' in context:
-            _, context = context.split(':')
-        expected_win_ids = tuple(int(idx) for idx in context.split(',') if idx)
-        info = (self._playbook_idx + 1,
-                'WANTED:', expected_win_ids, expected_msg,
-                'GOT:', win_ids, msg_sans_time)
-        assert expected_msg == msg_sans_time, info
-        assert expected_win_ids == win_ids, info
-        self._play_till_next_log()
-        return win_ids, logged_msg
-
-    def _play_till_next_log(self) -> None:
-        while True:
-            self._playbook_idx += 1
-            line = self._playbook[self._playbook_idx]
-            if line[:1] == '#' or not line.strip():
-                continue
-            context, msg = line.split(' ', maxsplit=1)
-            if context == 'repeat':
-                start, end = msg.split(':')
-                self._playbook = (self._playbook[:self._playbook_idx + 1]
-                                  + self._playbook[int(start):int(end)]
-                                  + self._playbook[self._playbook_idx + 1:])
-                continue
-            if context == '>':
-                for c in msg:
-                    self._q_keypresses.put(c)
-                self._q_keypresses.put('KEY_ENTER')
-                continue
-            if ':' in context and msg.startswith('< '):
-                client_id, win_ids = context.split(':')
-                client = self._clients[int(client_id)]
-                assert isinstance(client.conn, _FakeIrcConnection)
-                client.conn.put_server_msg(msg[2:])
-                if not win_ids:
-                    continue
-            break
diff --git a/ircplom/tui_base.py b/ircplom/tui_base.py
deleted file mode 100644 (file)
index 73115a1..0000000
+++ /dev/null
@@ -1,734 +0,0 @@
-'Base Terminal and TUI management.'
-# built-ins
-from abc import ABC, abstractmethod
-from base64 import b64decode
-from contextlib import contextmanager
-from datetime import datetime
-from inspect import _empty as inspect_empty, signature, stack
-from signal import SIGWINCH, signal
-from typing import (Callable, Generator, Iterator, NamedTuple, Optional,
-                    Sequence)
-# requirements.txt
-from blessed import Terminal as BlessedTerminal
-# ourselves
-from ircplom.events import AffectiveEvent, Loop, QueueMixin, QuitEvent
-
-_LOG_PREFIX_DEFAULT = '#'
-_LOG_PREFIX_ALERT = '!'
-
-_MIN_HEIGHT = 4
-_MIN_WIDTH = 32
-
-_TIMEOUT_KEYPRESS_LOOP = 0.5
-_B64_PREFIX = 'b64:'
-_OSC52_PREFIX = b']52;c;'
-_PASTE_DELIMITER = '\007'
-
-_PROMPT_TEMPLATE = '> '
-_PROMPT_ELL_IN = '<…'
-_PROMPT_ELL_OUT = '…>'
-
-_CHAR_RESIZE = chr(12)
-_KEYBINDINGS = {
-    'KEY_BACKSPACE': ('window.prompt.backspace',),
-    'KEY_ENTER': ('prompt_enter',),
-    'KEY_LEFT': ('window.prompt.move_cursor', 'left'),
-    'KEY_RIGHT': ('window.prompt.move_cursor', 'right'),
-    'KEY_UP': ('window.prompt.scroll', 'up'),
-    'KEY_DOWN': ('window.prompt.scroll', 'down'),
-    'KEY_PGUP': ('window.history.scroll', 'up'),
-    'KEY_PGDOWN': ('window.history.scroll', 'down'),
-    'esc:91:49:59:51:68': ('window', 'left'),
-    'esc:91:49:59:51:67': ('window', 'right'),
-    'KEY_F1': ('window.paste',),
-}
-CMD_SHORTCUTS: dict[str, str] = {}
-
-
-class _YX(NamedTuple):
-    y: int
-    x: int
-
-
-class _Widget(ABC):
-    _tainted: bool = True
-    _sizes = _YX(-1, -1)
-
-    @property
-    def _drawable(self) -> bool:
-        return len([m for m in self._sizes if m < 1]) == 0
-
-    def taint(self) -> None:
-        'Declare as in need of re-drawing.'
-        self._tainted = True
-
-    @property
-    def tainted(self) -> bool:
-        'If in need of re-drawing.'
-        return self._tainted
-
-    def set_geometry(self, sizes: _YX) -> None:
-        'Update widget\'s sizues, re-generate content where necessary.'
-        self.taint()
-        self._sizes = sizes
-
-    def draw(self) -> None:
-        'Print widget\'s content in shape appropriate to set geometry.'
-        if self._drawable:
-            self._draw()
-            self._tainted = False
-
-    @abstractmethod
-    def _draw(self) -> None:
-        pass
-
-
-class _ScrollableWidget(_Widget):
-    _history_idx: int
-
-    def __init__(self, write: Callable[..., None], **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._write = write
-        self._history: list[str] = []
-
-    def append(self, to_append: str) -> None:
-        'Append to scrollable history.'
-        self._history += [to_append]
-
-    @abstractmethod
-    def _scroll(self, up=True) -> None:
-        self.taint()
-
-    def cmd__scroll(self, direction: str) -> None:
-        'Scroll through stored content/history.'
-        self._scroll(up=direction == 'up')
-
-
-class _HistoryWidget(_ScrollableWidget):
-    _last_read: int = 0
-    _y_pgscroll: int
-
-    def __init__(self, wrap: Callable[[str], list[str]], **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._wrap = wrap
-        self._wrapped_idx = self._history_idx = -1
-        self._wrapped: list[tuple[Optional[int], str]] = []
-
-    def _add_wrapped(self, idx_original, line) -> int:
-        wrapped_lines = self._wrap(line)
-        self._wrapped += [(idx_original, line) for line in wrapped_lines]
-        return len(wrapped_lines)
-
-    def set_geometry(self, sizes: _YX) -> None:
-        super().set_geometry(sizes)
-        if self._drawable:
-            self._y_pgscroll = self._sizes.y // 2
-            self._wrapped.clear()
-            self._wrapped += [(None, '')] * self._sizes.y
-            if self._history:
-                for idx_history, line in enumerate(self._history):
-                    self._add_wrapped(idx_history, line)
-                wrapped_lines_for_history_idx = [
-                        t for t in self._wrapped
-                        if t[0] == len(self._history) + self._history_idx]
-                idx_their_last = self._wrapped.index(
-                        wrapped_lines_for_history_idx[-1])
-                self._wrapped_idx = idx_their_last - len(self._wrapped)
-
-    def append(self, to_append: str) -> None:
-        super().append(to_append)
-        self.taint()
-        if self._history_idx < -1:
-            self._history_idx -= 1
-        if self._drawable:
-            n_wrapped_lines = self._add_wrapped(len(self._history)
-                                                - 1, to_append)
-            if self._wrapped_idx < -1:
-                self._wrapped_idx -= n_wrapped_lines
-
-    def _draw(self) -> None:
-        start_idx = self._wrapped_idx - self._sizes.y + 1
-        end_idx = self._wrapped_idx
-        to_write = [t[1] for t in self._wrapped[start_idx:end_idx]]
-        if self._wrapped_idx < -1:
-            scroll_info = f'vvv [{(-1) * self._wrapped_idx}] '
-            scroll_info += 'v' * (self._sizes.x - len(scroll_info))
-            to_write += [scroll_info]
-        else:
-            to_write += [self._wrapped[self._wrapped_idx][1]]
-        for i, line in enumerate(to_write):
-            self._write(line, i)
-        self._last_read = len(self._history)
-
-    @property
-    def n_lines_unread(self) -> int:
-        'How many new lines have been logged since last focus.'
-        return len(self._history) - self._last_read
-
-    def _scroll(self, up: bool = True) -> None:
-        super()._scroll(up)
-        if self._drawable:
-            if up:
-                self._wrapped_idx = max(
-                        self._sizes.y + 1 - len(self._wrapped),
-                        self._wrapped_idx - self._y_pgscroll)
-            else:
-                self._wrapped_idx = min(
-                        -1, self._wrapped_idx + self._y_pgscroll)
-            history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0]
-            if history_idx_to_wrapped_idx is not None:
-                self._history_idx = history_idx_to_wrapped_idx\
-                        - len(self._history)
-
-
-class PromptWidget(_ScrollableWidget):
-    'Manages/displays keyboard input field.'
-    _history_idx: int = 0
-    _input_buffer_unsafe: str
-    _cursor_x: int
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._reset_buffer('')
-
-    @property
-    def prefix(self) -> str:
-        'Main input prefix.'
-        return _PROMPT_TEMPLATE[:]
-
-    @property
-    def _input_buffer(self) -> str:
-        return self._input_buffer_unsafe[:]
-
-    @_input_buffer.setter
-    def _input_buffer(self, content) -> None:
-        self.taint()
-        self._input_buffer_unsafe = content
-
-    def _draw(self) -> None:
-        prefix = self.prefix[:]
-        content = self._input_buffer
-        if self._cursor_x == len(self._input_buffer):
-            content += ' '
-        half_width = (self._sizes.x - len(prefix)) // 2
-        offset = 0
-        if len(prefix) + len(content) > self._sizes.x\
-                and self._cursor_x > half_width:
-            prefix += _PROMPT_ELL_IN
-            offset = min(len(prefix) + len(content) - self._sizes.x,
-                         self._cursor_x - half_width + len(_PROMPT_ELL_IN))
-        cursor_x_to_write = len(prefix) + self._cursor_x - offset
-        to_write = f'{prefix}{content[offset:]}'
-        if len(to_write) > self._sizes.x:
-            to_write = (to_write[:self._sizes.x-len(_PROMPT_ELL_OUT)]
-                        + _PROMPT_ELL_OUT)
-        self._write(to_write[:cursor_x_to_write], self._sizes.y,
-                    padding=False)
-        self._write(to_write[cursor_x_to_write], attribute='reverse',
-                    padding=False)
-        self._write(to_write[cursor_x_to_write + 1:])
-
-    def _archive_prompt(self) -> None:
-        self.append(self._input_buffer)
-        self._reset_buffer('')
-
-    def _scroll(self, up: bool = True) -> None:
-        super()._scroll(up)
-        if up and -(self._history_idx) < len(self._history):
-            if self._history_idx == 0 and self._input_buffer:
-                self._archive_prompt()
-                self._history_idx -= 1
-            self._history_idx -= 1
-            self._reset_buffer(self._history[self._history_idx])
-        elif not up:
-            if self._history_idx < 0:
-                self._history_idx += 1
-                if self._history_idx == 0:
-                    self._reset_buffer('')
-                else:
-                    self._reset_buffer(self._history[self._history_idx])
-            elif self._input_buffer:
-                self._archive_prompt()
-
-    def insert(self, to_insert: str) -> None:
-        'Insert into prompt input buffer.'
-        self._cursor_x += len(to_insert)
-        self._input_buffer = (self._input_buffer[:self._cursor_x - 1]
-                              + to_insert
-                              + self._input_buffer[self._cursor_x - 1:])
-        self._history_idx = 0
-
-    def cmd__backspace(self) -> None:
-        'Truncate current content by one character, if possible.'
-        if self._cursor_x > 0:
-            self._cursor_x -= 1
-            self._input_buffer = (self._input_buffer[:self._cursor_x]
-                                  + self._input_buffer[self._cursor_x + 1:])
-            self._history_idx = 0
-
-    def cmd__move_cursor(self, direction: str) -> None:
-        'Move cursor one space into direction ("left" or "right") if possible.'
-        if direction == 'left' and self._cursor_x > 0:
-            self._cursor_x -= 1
-        elif direction == 'right'\
-                and self._cursor_x < len(self._input_buffer):
-            self._cursor_x += 1
-        else:
-            return
-        self.taint()
-
-    def _reset_buffer(self, content: str) -> None:
-        self._input_buffer = content
-        self._cursor_x = len(self._input_buffer)
-
-    def enter(self) -> str:
-        'Return current content while also clearing and then redrawing.'
-        to_return = self._input_buffer[:]
-        if to_return:
-            self._archive_prompt()
-        return to_return
-
-
-class _StatusLine(_Widget):
-
-    def __init__(self, write: Callable, windows: list['Window'], **kwargs
-                 ) -> None:
-        super().__init__(**kwargs)
-        self.idx_focus = 0
-        self._windows = windows
-        self._write = write
-
-    def _draw(self) -> None:
-        listed = []
-        focused = None
-        for w in self._windows:
-            item = str(w.idx)
-            if (n := w.history.n_lines_unread):
-                item = f'({item}:{n})'
-            if w.idx == self.idx_focus:
-                focused = w
-                item = f'[{item}]'
-            listed += [item]
-        assert isinstance(focused, Window)
-        left = f'{focused.title})'
-        right = f'({" ".join(listed)}'
-        width_gap = max(1, (self._sizes.x - len(left) - len(right)))
-        self._write(left + '=' * width_gap + right, self._sizes.y)
-
-
-class Window:
-    'Collection of widgets filling entire screen.'
-    _y_status: int
-    _drawable = False
-    prompt: PromptWidget
-    _title = ':start'
-    _last_today = ''
-
-    def __init__(self, idx: int, term: 'Terminal', **kwargs) -> None:
-        super().__init__(**kwargs)
-        self.idx = idx
-        self._term = term
-        self.history = _HistoryWidget(wrap=self._term.wrap,
-                                      write=self._term.write)
-        self.prompt = self.__annotations__['prompt'](write=self._term.write)
-        if hasattr(self._term, 'size'):
-            self.set_geometry()
-
-    def ensure_date(self, today: str) -> None:
-        'Log date of today if it has not been logged yet.'
-        if today != self._last_today:
-            self._last_today = today
-            self.log(today)
-
-    def log(self, msg: str) -> None:
-        'Append msg to .history.'
-        self.history.append(msg)
-
-    def taint(self) -> None:
-        'Declare all widgets as in need of re-drawing.'
-        self.history.taint()
-        self.prompt.taint()
-
-    @property
-    def tainted(self) -> bool:
-        'If any widget in need of re-drawing.'
-        return self.history.tainted or self.prompt.tainted
-
-    def set_geometry(self) -> None:
-        'Set geometry for widgets.'
-        self._drawable = False
-        if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH:
-            for widget in (self.history, self.prompt):
-                widget.set_geometry(_YX(-1, -1))
-            return
-        self._y_status = self._term.size.y - 2
-        self.history.set_geometry(_YX(self._y_status, self._term.size.x))
-        self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
-        self._drawable = True
-
-    @property
-    def title(self) -> str:
-        'Window title to display in status line.'
-        return self._title
-
-    def draw_tainted(self) -> None:
-        'Draw tainted widgets (or message that screen too small).'
-        if self._drawable:
-            for widget in [w for w in (self.history, self.prompt)
-                           if w.tainted]:
-                widget.draw()
-        elif self._term.size.x > 0:
-            lines = ['']
-            for i, c in enumerate('screen too small'):
-                if i > 0 and 0 == i % self._term.size.x:
-                    lines += ['']
-                lines[-1] += c
-            for y, line in enumerate(lines):
-                self._term.write(line, y)
-
-    def cmd__paste(self) -> None:
-        'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
-        self.history.append(f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
-
-
-class TuiEvent(AffectiveEvent):
-    'To affect TUI.'
-
-
-class TerminalInterface(ABC):
-    'What BaseTui expects from a Terminal.'
-    size: _YX
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-
-    @abstractmethod
-    @contextmanager
-    def setup(self) -> Generator:
-        'Combine multiple contexts into one and run keypress loop.'
-
-    @abstractmethod
-    def calc_geometry(self) -> None:
-        '(Re-)calculate .size..'
-
-    @abstractmethod
-    def flush(self) -> None:
-        'Flush terminal.'
-
-    @abstractmethod
-    def wrap(self, line: str) -> list[str]:
-        'Wrap line to list of lines fitting into terminal width.'
-
-    @abstractmethod
-    def write(self,
-              msg: str = '',
-              start_y: Optional[int] = None,
-              attribute: Optional[str] = None,
-              padding: bool = True
-              ) -> None:
-        'Print to terminal, with position, padding to line end, attributes.'
-
-    @abstractmethod
-    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
-        pass
-
-
-class BaseTui(QueueMixin):
-    'Base for graphical user interface elements.'
-
-    def __init__(self, term: TerminalInterface, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._term = term
-        self._window_idx = 0
-        self._windows: list[Window] = []
-        self._status_line = _StatusLine(write=self._term.write,
-                                        windows=self._windows)
-        self._new_window()
-        self._set_screen()
-        signal(SIGWINCH, lambda *_: self._set_screen())
-
-    def _log_target_wins(self, **_) -> Sequence[Window]:
-        # separated to serve as hook for subclass window selection
-        return [self.window]
-
-    def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
-        'Write with timestamp, prefix to what window ._log_target_wins offers.'
-        prefix = kwargs.get('prefix', _LOG_PREFIX_DEFAULT)
-        if kwargs.get('alert', False):
-            prefix = _LOG_PREFIX_ALERT + prefix
-        now = str(datetime.now())
-        today, time = now[:10], now[11:19]
-        msg = f'{time} {prefix} {msg}'
-        affected_win_indices = []
-        for win in self._log_target_wins(**kwargs):
-            affected_win_indices += [win.idx]
-            win.ensure_date(today)
-            win.log(msg)
-            if win != self.window:
-                self._status_line.taint()
-        return tuple(affected_win_indices), msg
-
-    def _new_window(self, win_class=Window, **kwargs) -> Window:
-        new_idx = len(self._windows)
-        win = win_class(idx=new_idx, term=self._term, **kwargs)
-        self._windows += [win]
-        return win
-
-    def redraw_affected(self) -> None:
-        'On focused window call .draw, then flush screen.'
-        self.window.draw_tainted()
-        if self._status_line.tainted:
-            self._status_line.draw()
-        self._term.flush()
-
-    def _set_screen(self) -> None:
-        'Calc screen geometry into windows, then call .redraw_affected.'
-        self._term.calc_geometry()
-        for window in self._windows:
-            window.set_geometry()
-        self._status_line.set_geometry(_YX(self._term.size.y - 2,
-                                           self._term.size.x))
-        self.redraw_affected()
-
-    @property
-    def window(self) -> Window:
-        'Currently selected Window.'
-        return self._windows[self._window_idx]
-
-    def _switch_window(self, idx: int) -> None:
-        self.window.taint()
-        self._status_line.idx_focus = self._window_idx = idx
-        self._status_line.taint()
-
-    @property
-    def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]],
-                                           int, tuple[str, ...]]]:
-        cmds = {}
-        method_name_prefix = 'cmd__'
-        base = 'self'
-        for path in (base, f'{base}.window', f'{base}.window.prompt',
-                     f'{base}.window.history'):
-            for cmd_method_name in [name for name in dir(eval(path))
-                                    if name.startswith(method_name_prefix)]:
-                path_prefix = f'{path}.'
-                cmd_name = (path_prefix[len(base)+1:]
-                            + cmd_method_name[len(method_name_prefix):])
-                method = eval(f'{path_prefix}{cmd_method_name}')
-                n_args_min = 0
-                arg_names = []
-                for arg_name, param in signature(method).parameters.items():
-                    arg_names += [arg_name]
-                    n_args_min += int(param.default == inspect_empty)
-                cmds[cmd_name] = (method, n_args_min, tuple(arg_names))
-        for key, target in CMD_SHORTCUTS.items():
-            if target in cmds:
-                cmds[key] = cmds[target]
-        return cmds
-
-    def handle_keyboard_event(self, typed_in: str) -> None:
-        'Translate keyboard input into appropriate actions.'
-        if typed_in[0] == _CHAR_RESIZE:
-            self._set_screen()
-            return
-        if typed_in in _KEYBINDINGS:
-            cmd_data = _KEYBINDINGS[typed_in]
-            self._commands[cmd_data[0]][0](*cmd_data[1:])
-        elif typed_in.startswith(_B64_PREFIX):
-            encoded = typed_in[len(_B64_PREFIX):]
-            to_paste = ''
-            for i, c in enumerate(b64decode(encoded).decode('utf-8')):
-                if i > 512:
-                    break
-                if c.isprintable():
-                    to_paste += c
-                elif c.isspace():
-                    to_paste += ' '
-                else:
-                    to_paste += '#'
-            self.window.prompt.insert(to_paste)
-        elif len(typed_in) == 1:
-            self.window.prompt.insert(typed_in)
-        else:
-            self.log(f'unknown keyboard input: {typed_in}', alert=True)
-        self.redraw_affected()
-
-    def cmd__prompt_enter(self) -> None:
-        'Get prompt content from .window.prompt.enter, parse to & run command'
-        to_parse = self.window.prompt.enter()
-        if not to_parse:
-            return
-        alert: Optional[str] = None
-        if to_parse[0] == '/':
-            toks = to_parse.split(maxsplit=1)
-            cmd_name = toks.pop(0)
-            cmd, n_args_min, arg_names = self._commands.get(cmd_name[1:],
-                                                            (None, 0, ()))
-            if not cmd:
-                alert = f'{cmd_name} unknown'
-            elif cmd.__name__ == stack()[0].function:
-                alert = f'{cmd_name} would loop into ourselves'
-            else:
-                n_args_max = len(arg_names)
-                if toks and not n_args_max:
-                    alert = f'{cmd_name} given argument(s) while none expected'
-                else:
-                    if toks:
-                        while ' ' in toks[-1] and len(toks) < n_args_max:
-                            toks = toks[:-1] + toks[-1].split(maxsplit=1)
-                    if len(toks) < n_args_min:
-                        alert = f'{cmd_name} too few arguments '\
-                                + f'(given {len(toks)}, need {n_args_min})'
-                    else:
-                        alert = cmd(*toks)
-        else:
-            alert = 'not prefixed by /'
-        if alert:
-            self.log(f'invalid prompt command: {alert}', alert=True)
-
-    def cmd__help(self) -> None:
-        'Print available commands.'
-        self.log('commands available in this window:')
-        to_log = []
-        for cmd_name, cmd_data in self._commands.items():
-            to_print = [cmd_name]
-            for idx, arg in enumerate(cmd_data[2]):
-                arg = arg.upper()
-                if idx >= cmd_data[1]:
-                    arg = f'[{arg}]'
-                to_print += [arg]
-            to_log += [' '.join(to_print)]
-        for item in sorted(to_log):
-            self.log(f'  /{item}')
-
-    def cmd__list(self) -> None:
-        'List available windows.'
-        self.log('windows available via /window:')
-        for win in self._windows:
-            self.log(f'  {win.idx}) {win.title}')
-
-    def cmd__quit(self) -> None:
-        'Trigger program exit.'
-        self._put(QuitEvent())
-
-    def cmd__window(self, towards: str) -> Optional[str]:
-        'Switch window selection.'
-        n_windows = len(self._windows)
-        if n_windows < 2:
-            return 'no alternate window to move into'
-        if towards in {'left', 'right'}:
-            multiplier = (+1) if towards == 'right' else (-1)
-            window_idx = self._window_idx + multiplier
-            if not 0 <= window_idx < n_windows:
-                window_idx -= multiplier * n_windows
-        elif not towards.isdigit():
-            return f'neither "left"/"right" nor integer: {towards}'
-        else:
-            window_idx = int(towards)
-            if not 0 <= window_idx < n_windows:
-                return f'unavailable window idx: {window_idx}'
-        self._switch_window(window_idx)
-        return None
-
-
-class Terminal(QueueMixin, TerminalInterface):
-    'Abstraction of terminal interface.'
-    _cursor_yx_: _YX
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._blessed = BlessedTerminal()
-        self._cursor_yx = _YX(0, 0)
-
-    @contextmanager
-    def setup(self) -> Generator:
-        print(self._blessed.clear, end='')
-        with (self._blessed.raw(),
-              self._blessed.fullscreen(),
-              self._blessed.hidden_cursor(),
-              Loop(iterator=self._get_keypresses(), _q_out=self._q_out)):
-            yield self
-
-    @property
-    def _cursor_yx(self) -> _YX:
-        return self._cursor_yx_
-
-    @_cursor_yx.setter
-    def _cursor_yx(self, yx: _YX) -> None:
-        print(self._blessed.move_yx(yx.y, yx.x), end='')
-        self._cursor_yx_ = yx
-
-    def calc_geometry(self) -> None:
-        self.size = _YX(self._blessed.height, self._blessed.width)
-
-    def flush(self) -> None:
-        print('', end='', flush=True)
-
-    def wrap(self, line: str) -> list[str]:
-        return self._blessed.wrap(line, width=self.size.x,
-                                  subsequent_indent=' '*4)
-
-    def write(self,
-              msg: str = '',
-              start_y: Optional[int] = None,
-              attribute: Optional[str] = None,
-              padding: bool = True
-              ) -> None:
-        if start_y is not None:
-            self._cursor_yx = _YX(start_y, 0)
-        # ._blessed.length can slow down things notably: only use where needed!
-        end_x = self._cursor_yx.x + (len(msg) if msg.isascii()
-                                     else self._blessed.length(msg))
-        len_padding = self.size.x - end_x
-        if len_padding < 0:
-            msg = self._blessed.truncate(msg, self.size.x - self._cursor_yx.x)
-        elif padding:
-            msg += ' ' * len_padding
-            end_x = self.size.x
-        if attribute:
-            msg = getattr(self._blessed, attribute)(msg)
-        print(msg, end='')
-        self._cursor_yx = _YX(self._cursor_yx.y, end_x)
-
-    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
-        '''Loop through keypresses from terminal, expand blessed's handling.
-
-        Explicitly collect KEY_ESCAPE-modified key sequences, and recognize
-        OSC52-prefixed pastables to return the respective base64 code,
-        prefixed with _B64_PREFIX.
-        '''
-        while True:
-            to_yield = ''
-            ks = self._blessed.inkey(
-                timeout=_TIMEOUT_KEYPRESS_LOOP,  # how long until yield None,
-                esc_delay=0)                     # incl. until thread dies
-            if ks.name != 'KEY_ESCAPE':
-                to_yield = f'{ks.name if ks.name else ks}'
-            else:
-                chars = b''
-                while (new_chars := self._blessed.inkey(timeout=0, esc_delay=0
-                                                        ).encode('utf-8')):
-                    chars += new_chars
-                len_prefix = len(_OSC52_PREFIX)
-                if chars[:len_prefix] == _OSC52_PREFIX:
-                    to_yield = _B64_PREFIX[:]
-                    # sometimes, prev .inkey got some or all (including paste
-                    # delimiter) of the paste code (maybe even more), so first
-                    # harvest potential remains of chars post prefix …
-                    caught_delimiter = False
-                    post_prefix_str = chars[len_prefix:].decode('utf-8')
-                    for idx, c in enumerate(post_prefix_str):
-                        if c == _PASTE_DELIMITER:
-                            caught_delimiter = True
-                            if (remains := post_prefix_str[idx + 1:]):
-                                self._blessed.ungetch(remains)
-                            break
-                        to_yield += c
-                    # … before .getch() further until expected delimiter found
-                    if not caught_delimiter:
-                        while (c := self._blessed.getch()) != _PASTE_DELIMITER:
-                            to_yield += c
-                else:
-                    to_yield = 'esc:' + ':'.join([str(int(b)) for b in chars])
-            yield (TuiEvent.affector('handle_keyboard_event'
-                                     ).kw(typed_in=to_yield) if to_yield
-                   else None)
diff --git a/plomlib b/plomlib
new file mode 160000 (submodule)
index 0000000..f2dc66a
--- /dev/null
+++ b/plomlib
@@ -0,0 +1 @@
+Subproject commit f2dc66a2d4f1e8823246d1621b424e44ec423897
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644 (file)
index d43de1b..0000000
+++ /dev/null
@@ -1 +0,0 @@
-blessed
diff --git a/src/ircplom/__init__.py b/src/ircplom/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/ircplom/client.py b/src/ircplom/client.py
new file mode 100644 (file)
index 0000000..d0bc4b8
--- /dev/null
@@ -0,0 +1,984 @@
+'High-level IRC protocol / server connection management.'
+# built-ins
+from abc import ABC, abstractmethod
+from base64 import b64encode
+from dataclasses import dataclass, InitVar
+from getpass import getuser
+from threading import Thread
+from typing import (Any, Callable, Collection, Generic, Iterable, Iterator,
+                    Optional, Self, Set, TypeVar)
+# ourselves
+from ircplom.events import (
+    AffectiveEvent, CrashingException, ExceptionEvent, QueueMixin)
+from ircplom.irc_conn import (
+    BaseIrcConnection, IrcConnAbortException, IrcMessage,
+    ILLEGAL_NICK_CHARS, ILLEGAL_NICK_FIRSTCHARS, ISUPPORT_DEFAULTS, PORT_SSL)
+from ircplom.msg_parse_expectations import MSG_EXPECTATIONS
+
+
+_NAMES_DESIRED_SERVER_CAPS = ('sasl',)
+
+
+class SendFail(BaseException):
+    'When Client.send fails.'
+
+
+class TargetUserOffline(BaseException):
+    'When according to server our target user is not to be found.'
+
+
+class ImplementationFail(BaseException):
+    'When no matching parser found for server message.'
+
+
+class AutoAttrMixin:
+    'Ensures attribute as defined by annotations along MRO'
+
+    @classmethod
+    def _deep_annotations(cls) -> dict[str, type]:
+        types: dict[str, type] = {}
+        for c in cls.__mro__:
+            if hasattr(c, '__annotations__'):
+                types = c.__annotations__ | types
+        return {k: v for k, v in types.items() if k[0] != '_'}
+
+    def __getattribute__(self, key: str):
+        if key[0] != '_' and (cls := self._deep_annotations().get(key, None)):
+            try:
+                return super().__getattribute__(key)
+            except AttributeError:
+                setattr(self, key, self._make_attr(cls, key))
+        return super().__getattribute__(key)
+
+
+class _Clearable(ABC):
+
+    @abstractmethod
+    def clear(self) -> None:
+        'Zero internal knowledge.'
+
+
+DictItem = TypeVar('DictItem')
+
+
+class Dict(_Clearable, Generic[DictItem]):
+    'Customized dict replacement.'
+
+    def __init__(self, **kwargs) -> None:
+        self._dict: dict[str, DictItem] = {}
+        super().__init__(**kwargs)
+
+    def keys(self) -> tuple[str, ...]:
+        'Keys of item registrations.'
+        return tuple(self._dict.keys())
+
+    def __getitem__(self, key: str) -> DictItem:
+        return self._dict[key]
+
+    def clear(self) -> None:
+        self._dict.clear()
+
+    @property
+    def _item_cls(self):
+        orig_cls = (self.__orig_class__ if hasattr(self, '__orig_class__')
+                    else self.__orig_bases__[0])
+        return orig_cls.__args__[0]
+
+
+class _Dict(Dict[DictItem]):
+
+    def __setitem__(self, key: str, val: DictItem) -> None:
+        assert isinstance(val, self._item_cls)
+        self._dict[key] = val
+
+    def __delitem__(self, key: str) -> None:
+        del self._dict[key]
+
+    def values(self) -> tuple[DictItem, ...]:
+        'Items registered.'
+        return tuple(self._dict.values())
+
+    @staticmethod
+    def key_val_from_eq_str(eq_str: str) -> tuple[str, str]:
+        'Split eq_str by "=", and if none, into eq_str and "".'
+        toks = eq_str.split('=', maxsplit=1)
+        return toks[0], '' if len(toks) == 1 else toks[1]
+
+
+class _Completable(ABC):
+    completed: Any
+
+    @abstractmethod
+    def complete(self) -> None:
+        'Set .completed to "complete" value if possible of current state.'
+
+
+class _CompletableStringsCollection(_Completable, Collection):
+    _collected: Collection[str]
+    completed: Optional[Collection[str]] = None
+
+    @abstractmethod
+    def _copy_collected(self) -> Collection[str]:
+        pass
+
+    def complete(self) -> None:
+        self.completed = self._copy_collected()
+
+    def __len__(self) -> int:
+        assert self.completed is not None
+        return len(self.completed)
+
+    def __contains__(self, item) -> bool:
+        assert self.completed is not None
+        assert isinstance(item, str)
+        return item in self.completed
+
+    def __iter__(self) -> Iterator[str]:
+        assert self.completed is not None
+        yield from self.completed
+
+
+class _CompletableStringsSet(_CompletableStringsCollection, Set):
+    _collected: set[str]
+    completed: Optional[set[str]] = None
+
+    def __init__(self) -> None:
+        self._collected = set()
+
+    def _copy_collected(self) -> set[str]:
+        return set(self._collected)
+
+    def _on_set(self, m_name: str, item: str, on_complete: bool, exists: bool
+                ) -> None:
+        method = getattr(self._collected, m_name)
+        if on_complete:
+            assert self.completed is not None
+            assert (item in self.completed) == exists
+            assert (item in self._collected) == exists
+            method(item)
+            self.complete()
+        else:
+            assert self.completed is None
+            assert (item in self._collected) == exists
+            method(item)
+
+    def completable_add(self, item: str, on_complete: bool) -> None:
+        'Put item into collection.'
+        self._on_set('add', item, on_complete, False)
+
+    def completable_remove(self, item: str, on_complete: bool) -> None:
+        'Remove item from collection.'
+        self._on_set('remove', item, on_complete, True)
+
+    def intersection(self, *others: Iterable[str]) -> set[str]:
+        'Compare self to other set(s).'
+        assert self.completed is not None
+        result = self.completed
+        for other in others:
+            assert isinstance(other, set)
+            result = result.intersection(other)
+        return result
+
+
+class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection):
+    _collected: tuple[str, ...] = tuple()
+    completed: Optional[tuple[str, ...]] = tuple()
+
+    def _copy_collected(self) -> tuple[str, ...]:
+        return tuple(self._collected)
+
+    def append(self, value: str, complete=False) -> None:
+        'Append value to list.'
+        self._collected += (value,)
+        if complete:
+            self.complete()
+
+    def clear(self) -> None:
+        self._collected = tuple()
+        self.complete()
+
+
+class _UpdatingMixin:
+
+    def __init__(self, on_update: Callable, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._on_update = on_update
+
+
+class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin):
+
+    def _make_attr(self, cls: Callable, key: str):
+        return cls(on_update=lambda *steps: self._on_update(key, *steps))
+
+    def __setattr__(self, key: str, value) -> None:
+        super().__setattr__(key, value)
+        if hasattr(self, '_on_update') and key in self._deep_annotations():
+            self._on_update(key)
+
+    def attr_names(self) -> tuple[str, ...]:
+        'Names of (deeply) annotated attributes.'
+        return tuple(self._deep_annotations().keys())
+
+
+class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]):
+    _create_if_none: Optional[dict[str, Any]] = None
+
+    def __bool__(self) -> bool:
+        return bool(self._dict)
+
+    def __getitem__(self, key: str) -> DictItem:
+        if key not in self._dict:
+            if self._create_if_none is not None:
+                kw = {} | self._create_if_none
+                if _UpdatingMixin in self._item_cls.__mro__:
+                    kw |= {'on_update':
+                           lambda *steps: self._on_update(key, *steps)}
+                self[key] = self._item_cls(**kw)
+        return super().__getitem__(key)
+
+    def __setitem__(self, key: str, val: DictItem) -> None:
+        super().__setitem__(key, val)
+        self._on_update(key)
+
+    def __delitem__(self, key: str) -> None:
+        super().__delitem__(key)
+        self._on_update(key)
+
+    def clear(self) -> None:
+        super().clear()
+        self._on_update()
+
+
+class _UpdatingCompletable(_UpdatingMixin, _Completable):
+
+    def complete(self) -> None:
+        super().complete()  # type: ignore
+        self._on_update()
+
+
+class _UpdatingCompletableStringsSet(
+        _UpdatingCompletable, _CompletableStringsSet):
+    pass
+
+
+class _UpdatingCompletableStringsOrdered(
+        _UpdatingCompletable, _CompletableStringsOrdered):
+    pass
+
+
+@dataclass
+class IrcConnSetup:
+    'All we need to know to set up a new Client connection.'
+    hostname: str = ''
+    port: int = 0
+    nick_wanted: str = ''
+    user_wanted: str = ''
+    realname: str = ''
+    password: str = ''
+
+
+class ChatMessage:
+    'Collects all we want to know on incoming PRIVMSG or NOTICE chat message.'
+    content: str = ''
+    sender: str = ''
+    target: str = ''
+    is_notice: bool = False
+
+    def __str__(self) -> str:
+        return f'{"N" if self.is_notice else "P"}|'\
+                + f'{self.sender}|{self.target}|{self.content}'
+
+    def __bool__(self) -> bool:
+        return bool(self.content + self.sender + self.target) | self.is_notice
+
+
+class SharedClientDbFields(IrcConnSetup):
+    'API for fields shared directly in name and type with TUI.'
+    connection_state: str = ''
+    isupport: Dict[str]
+    motd: Iterable[str]
+    sasl_account: str = ''
+    sasl_auth_state: str = ''
+    message: ChatMessage = ChatMessage()
+
+    def is_chan_name(self, name: str) -> bool:
+        'Tests name to match CHANTYPES prefixes.'
+        return name[0] in self.isupport['CHANTYPES']
+
+
+@dataclass
+class NickUserHost:
+    'Combination of nickname, username on host, and host.'
+    nick: str = '?'
+    user: str = '?'
+    host: str = '?'
+
+    def __str__(self) -> str:
+        return f'{self.nick}!{self.user}@{self.host}'
+
+
+class User(NickUserHost):
+    'Adds to NickUserHost non-naming-specific attributes.'
+    modes: str = '?'
+    exit_msg: str = ''
+
+
+@dataclass
+class ServerCapability:
+    'Public API for CAP data.'
+    data: str = ''
+    enabled: bool = False
+
+
+@dataclass
+class Topic:
+    'Collects both setter and content of channel topic.'
+    what: str = ''
+    who: Optional[NickUserHost] = None
+
+
+class Channel:
+    'Collects .topic, and in .user_ids inhabitant IDs.'
+    topic: Topic
+    user_ids: Iterable[str]
+    exits: Dict[str]
+
+
+@dataclass
+class _ClientIdMixin:
+    'Collects a Client\'s ID at .client_id.'
+    client_id: str
+
+
+@dataclass
+class ClientQueueMixin(QueueMixin, _ClientIdMixin):
+    'To QueueMixin adds _cput to send ClientEvent for self.'
+
+    def _client_trigger(self, t_method: str, **kwargs) -> None:
+        self._put(ClientEvent.affector(t_method, client_id=self.client_id
+                                       ).kw(**kwargs))
+
+
+@dataclass
+class IrcConnection(BaseIrcConnection, _ClientIdMixin):
+    'Parent extended to work with Client.'
+    hostname: InitVar[str]  # needed by BaseIrcConnection, but not desired as
+    port: InitVar[int]      # dataclass fields, only for __post_init__ call
+
+    def __post_init__(self, hostname, port, **kwargs) -> None:
+        super().__init__(hostname=hostname, port=port, _q_out=self._q_out,
+                         **kwargs)
+
+    def _make_recv_event(self, msg: IrcMessage) -> 'ClientEvent':
+        return ClientEvent.affector('handle_msg', client_id=self.client_id
+                                    ).kw(msg=msg)
+
+    def _on_handled_loop_exception(self, e: IrcConnAbortException
+                                   ) -> 'ClientEvent':
+        return ClientEvent.affector('on_handled_loop_exception',
+                                    client_id=self.client_id).kw(e=e)
+
+
+class _CompletableTopic(_Completable, Topic):
+    completed: Optional[Topic] = None
+
+    def complete(self) -> None:
+        assert self.who is not None
+        copy = _NickUserHost.from_str(str(self.who))
+        self.completed = Topic(self.what,
+                               NickUserHost(copy.nick, copy.user, copy.host))
+
+
+class _Channel(Channel):
+    user_ids: _CompletableStringsSet
+    topic: _CompletableTopic
+    exits: _Dict[str]
+
+    def __init__(self,
+                 userid_for_nickuserhost: Callable,
+                 get_membership_prefixes: Callable,
+                 purge_users: Callable,
+                 **kwargs
+                 ) -> None:
+        self._userid_for_nickuserhost = userid_for_nickuserhost
+        self._get_membership_prefixes = get_membership_prefixes
+        self.purge_users = purge_users
+        super().__init__(**kwargs)
+
+    def add_from_namreply(self, items: tuple[str, ...]) -> None:
+        'Add to .user_ids items assumed as nicknames with membership prefixes.'
+        for item in items:
+            n_u_h = NickUserHost(item.lstrip(self._get_membership_prefixes()))
+            user_id = self._userid_for_nickuserhost(n_u_h, create_if_none=True)
+            self.user_ids.completable_add(user_id, on_complete=False)
+
+    def add_user(self, user: '_User') -> None:
+        'To .user_ids add user.nickname, keep .user_ids declared complete.'
+        user_id = self._userid_for_nickuserhost(user, create_if_none=True,
+                                                updating=True)
+        self.user_ids.completable_add(user_id, on_complete=True)
+
+    def remove_user(self, user: '_User', msg: str) -> None:
+        'From .user_ids remove .nickname, keep .user_ids declared complete.'
+        self.exits[user.id_] = msg
+        self.user_ids.completable_remove(user.id_, on_complete=True)
+        del self.exits[user.id_]
+        self.purge_users()
+
+
+class _ChatMessage(ChatMessage):
+
+    def __init__(self,
+                 sender: str | NickUserHost = '',
+                 db: Optional['_ClientDb'] = None
+                 ) -> None:
+        self.sender = sender if isinstance(sender, str) else sender.nick
+        self._db = db
+
+    def to(self, target: str) -> Self:
+        'Extend self with .target, return self.'
+        self.target = target
+        return self
+
+    def __setattr__(self, key: str, value: str) -> None:
+        if key in {'privmsg', 'notice'}:
+            assert self._db is not None
+            self.is_notice = key == 'notice'
+            self.content = value
+            self._db.message = self
+            # to clean update cache, enabling equal messages in direct sequence
+            self._db.message = ChatMessage()
+        else:
+            super().__setattr__(key, value)
+
+
+class _SetNickuserhostMixin:
+
+    def __setattr__(self, key: str, value: NickUserHost | str) -> None:
+        if key == 'nickuserhost' and isinstance(value, NickUserHost):
+            for annotated_key in NickUserHost.__annotations__:
+                setattr(self, annotated_key, getattr(value, annotated_key))
+        else:
+            super().__setattr__(key, value)
+
+
+class _NickUserHost(NickUserHost):
+
+    @staticmethod
+    def possible_from(value: str) -> bool:
+        'If class instance could be parsed from value.'
+        toks = value.split('!')
+        if not len(toks) == 2:
+            return False
+        toks = toks[1].split('@')
+        if not len(toks) == 2:
+            return False
+        return True
+
+    @classmethod
+    def from_str(cls, value: str) -> Self:
+        'Produce from string assumed to fit _!_@_ pattern.'
+        assert cls.possible_from(value)
+        toks = value.split('!')
+        toks = toks[0:1] + toks[1].split('@')
+        return cls(*toks)
+
+    @property
+    def incremented(self) -> str:
+        'Return .nick with number suffix incremented, or "0" if none.'
+        name, digits = ([(self.nick, '')]
+                        + [(self.nick[:i], self.nick[i:])
+                           for i in range(len(self.nick), 0, -1)
+                           if self.nick[i:].isdigit()]
+                        )[-1]
+        return name + str(0 if not digits else (int(digits) + 1))
+
+
+class _User(_SetNickuserhostMixin, User):
+
+    def __init__(self,
+                 names_channels_of_user: Callable,
+                 remove_from_channels: Callable,
+                 **kwargs) -> None:
+        self.names_channels = lambda: names_channels_of_user(self)
+        self._remove_from_channels = lambda target, msg: remove_from_channels(
+                self, target, msg)
+        super().__init__(**kwargs)
+
+    def part(self, channel_name: str, exit_msg: str) -> None:
+        'First set .exit_msg, then remove from channel of channel_name.'
+        self._remove_from_channels(channel_name, f'P{exit_msg}')
+
+    def quit(self, exit_msg: str) -> None:
+        'First set .exit_msg, then remove from any channels.'
+        self.exit_msg = f'Q{exit_msg}'
+        self._remove_from_channels('', self.exit_msg)
+
+    @property
+    def id_(self) -> str:
+        'To be set to key inside dictionary if placed into one.'
+        return self._id_
+
+    @id_.setter
+    def id_(self, value: str) -> None:
+        self._id_ = value
+
+
+class _UpdatingServerCapability(_UpdatingAttrsMixin, ServerCapability):
+    pass
+
+
+class _UpdatingCompletableTopic(_UpdatingCompletable, _CompletableTopic):
+    pass
+
+
+class _UpdatingChannel(_UpdatingAttrsMixin, _Channel):
+    user_ids: _UpdatingCompletableStringsSet
+    topic: _UpdatingCompletableTopic
+    exits: _UpdatingDict[str]
+
+
+class _UpdatingUser(_UpdatingAttrsMixin, _User):
+    pass
+
+
+class _UpdatingUsersDict(_UpdatingDict[_UpdatingUser]):
+    _top_id: int
+    userlen: int
+
+    def __getitem__(self, key: str) -> _UpdatingUser:
+        user = super().__getitem__(key)
+        user.id_ = key
+        return user
+
+    def clear(self) -> None:
+        super().clear()
+        self._top_id = 0
+        self._on_update()
+
+    def id_for_nickuserhost(self,
+                            nickuserhost: NickUserHost,
+                            create_if_none=False,
+                            allow_none=False,
+                            updating=False
+                            ) -> Optional[str]:
+        'Return user_id for nickuserhost.nick, create if none, maybe update.'
+        matches = [id_ for id_, user in self._dict.items()
+                   if user.nick == nickuserhost.nick]
+        assert len(matches) in ({0, 1} if (create_if_none or allow_none)
+                                else {1})
+        if len(matches) == 1:
+            id_ = matches[0]
+            if '?' in {nickuserhost.user, nickuserhost.host}:
+                assert nickuserhost.user == nickuserhost.host  # both are '?'
+                # only provided with .nick, no fields we could update
+                return id_
+            stored = self._dict[id_]
+            # .nick by definition same, check other fields for updatability;
+            # allow where '?', or for set .user only to add "~" prefix, assert
+            # nothing else could have changed
+            if stored.user == '?'\
+                    or nickuserhost.user == f'~{stored.user}'[:self.userlen]:
+                assert updating
+                stored.user = nickuserhost.user
+            else:
+                assert stored.user == nickuserhost.user
+            if stored.host == '?':
+                assert updating
+                stored.host = nickuserhost.host
+            else:
+                assert stored.host == nickuserhost.host
+        elif create_if_none:
+            self._top_id += 1
+            id_ = str(self._top_id)
+            self[id_].nickuserhost = nickuserhost
+        else:
+            return None
+        return id_
+
+    def purge(self) -> None:
+        'Remove all not linked to by existing channels, except of our ID "me".'
+        for id_ in [id_ for id_, user in self._dict.items()
+                    if id_ != 'me' and not user.names_channels()]:
+            del self[id_]
+
+
+class _UpdatingChannelsDict(_UpdatingDict[_UpdatingChannel]):
+
+    def _of_user(self, user: _User) -> dict[str, _UpdatingChannel]:
+        return {k: v for k, v in self._dict.items() if user.id_ in v.user_ids}
+
+    def of_user(self, user: _User) -> tuple[str, ...]:
+        'Return names of channels listing user as member.'
+        return tuple(self._of_user(user).keys())
+
+    def remove_user(self, user: _User, target: str, msg: str) -> None:
+        'Remove user from channel named "target", or all with user if empty.'
+        if target:
+            self[target].remove_user(user, msg)
+        else:
+            for channel in self._of_user(user).values():
+                channel.remove_user(user, msg)
+
+
+class _UpdatingIsupportDict(_UpdatingDict[str]):
+
+    def __delitem__(self, key: str) -> None:
+        if key in ISUPPORT_DEFAULTS:
+            self[key] = ISUPPORT_DEFAULTS[key]
+        else:
+            super().__delitem__(key)
+
+    def clear(self) -> None:
+        super().clear()
+        for key, value in ISUPPORT_DEFAULTS.items():
+            self[key] = value
+
+
+class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields):
+    _updates_cache: dict[tuple[str, ...], Any] = {}
+    _keep_on_clear = set(IrcConnSetup.__annotations__.keys())
+    caps: _UpdatingDict[_UpdatingServerCapability]
+    channels: _UpdatingChannelsDict
+    isupport: _UpdatingIsupportDict
+    motd: _UpdatingCompletableStringsOrdered
+    users: _UpdatingUsersDict
+
+    def __getattribute__(self, key: str):
+        attr = super().__getattribute__(key)
+        if key == 'channels' and attr._create_if_none is None\
+                and super().__getattribute__('users'
+                                             )._create_if_none is not None:
+            attr._create_if_none = {
+                    'userid_for_nickuserhost': self.users.id_for_nickuserhost,
+                    'get_membership_prefixes': self._get_membership_prefixes,
+                    'purge_users': self.users.purge}
+        elif key == 'users':
+            attr.userlen = int(self.isupport['USERLEN'])
+            if attr._create_if_none is None:
+                attr._create_if_none = {
+                        'names_channels_of_user': self.channels.of_user,
+                        'remove_from_channels': self.channels.remove_user}
+        elif key == 'caps' and attr._create_if_none is None:
+            attr._create_if_none = {}
+        return attr
+
+    def messaging(self, src: str | NickUserHost) -> ChatMessage:
+        'Start input chain for chat message data.'
+        return _ChatMessage(sender=src, db=self)
+
+    def into_endnode_updates(self, path: tuple[str, ...]
+                             ) -> list[tuple[tuple[str, ...], Any]]:
+        'Return path-value pairs for update-worthy (sub-)elements at path.'
+
+        def update_unless_cached(path: tuple[str, ...], val: Any
+                                 ) -> list[tuple[tuple[str, ...], Any]]:
+            if path not in self._updates_cache\
+                    or val != self._updates_cache[path]:
+                self._updates_cache[path] = val
+                return [(path, val)]
+            return []
+
+        parent = self
+        cache_path: tuple[str, ...] = tuple()
+        for step in path[:-1]:
+            cache_path = cache_path + (step,)
+            if cache_path in self._updates_cache:
+                del self._updates_cache[cache_path]
+            parent = (parent[step] if isinstance(parent, Dict)
+                      else getattr(parent, step))
+        last_step = path[-1]
+        val_at_path = ((parent[last_step] if last_step in parent.keys()
+                        else None) if isinstance(parent, Dict)
+                       else getattr(parent, last_step))
+        if not isinstance(val_at_path, _UpdatingMixin):
+            return update_unless_cached(path, val_at_path)
+        if isinstance(val_at_path, _Completable):
+            if val_at_path.completed is not None:
+                return update_unless_cached(path, val_at_path.completed)
+            return []
+        if isinstance(val_at_path, Dict) and not val_at_path.keys():
+            for cache_path in [p for p in self._updates_cache
+                               if len(p) > len(path)
+                               and p[:len(path)] == path]:
+                del self._updates_cache[cache_path]
+            return update_unless_cached(path, None)
+        if isinstance(val_at_path, Dict):
+            sub_items = val_at_path.keys()
+        else:
+            assert isinstance(val_at_path, _UpdatingAttrsMixin)
+            sub_items = val_at_path.attr_names()
+        updates = []
+        for key in sub_items:
+            p = path + (key,)
+            updates += self.into_endnode_updates(p)
+        return updates
+
+    def clear(self) -> None:
+        for key, value in [(k, v) for k, v in self._deep_annotations().items()
+                           if k not in self._keep_on_clear]:
+            if hasattr(value, '__origin__'):
+                value = value.__origin__
+            if issubclass(value, _Clearable):
+                getattr(self, key).clear()
+            elif issubclass(value, str):
+                setattr(self, key, '')
+
+    def is_nick(self, nick: str) -> bool:
+        'Tests name to match rules for nicknames.'
+        if len(nick) == 0:
+            return False
+        if nick[0] in (ILLEGAL_NICK_FIRSTCHARS
+                       + self.isupport['CHANTYPES']
+                       + self._get_membership_prefixes()):
+            return False
+        for c in [c for c in nick if c in ILLEGAL_NICK_CHARS]:
+            return False
+        return True
+
+    def _get_membership_prefixes(self) -> str:
+        'Registered possible membership nickname prefixes.'
+        toks = self.isupport['PREFIX'].split(')', maxsplit=1)
+        assert len(toks) == 2
+        assert toks[0][0] == '('
+        return toks[1]
+
+
+class _CapsManager(_Clearable):
+
+    def __init__(self,
+                 sender: Callable,
+                 caps_dict: _UpdatingDict[_UpdatingServerCapability]
+                 ) -> None:
+        self._dict = caps_dict
+        self._send = lambda *args: sender('CAP', *args)
+        self.clear()
+
+    def clear(self) -> None:
+        self._dict.clear()
+        self._ls = _CompletableStringsSet()
+        self._list = _CompletableStringsSet()
+        self._list_expectations: dict[str, set[str]] = {
+                'ACK': set(), 'NAK': set()}
+
+    def start_negotation(self) -> None:
+        'Call .clear, send CAPS LS 302.'
+        self.clear()
+        self._send('LS', '302')
+
+    def end_negotiation(self) -> None:
+        'Stop negotation, without emptying caps DB.'
+        self._send('END')
+
+    def process_msg(self, verb: str, items: tuple[str, ...], complete: bool
+                    ) -> bool:
+        'Parse CAP message to negot. steps, DB inputs; return if successful.'
+        for item in items:
+            if verb == 'NEW':
+                key, data = _Dict.key_val_from_eq_str(item)
+                self._dict[key].data = data
+            elif verb == 'DEL':
+                del self._dict[item]
+            elif verb in {'ACK', 'NAK'}:
+                self._list_expectations[verb].add(item)
+        if verb in {'LS', 'LIST'}:
+            target = getattr(self, f'_{verb.lower()}')
+            for item in items:
+                target.completable_add(item, False)
+            if complete:
+                target.complete()
+                if target is self._ls:
+                    for cap_name in _NAMES_DESIRED_SERVER_CAPS:
+                        self._send('REQ', cap_name)
+                    self._send('LIST')
+                else:
+                    acks = self._list_expectations['ACK']
+                    naks = self._list_expectations['NAK']
+                    assert acks == self._list.intersection(acks)
+                    assert set() == self._list.intersection(naks)
+                    for key, data in [_Dict.key_val_from_eq_str(entry)
+                                      for entry in sorted(self._ls)]:
+                        self._dict[key].data = data
+                        self._dict[key].enabled = key in self._list
+                    return True
+        return False
+
+
+class Client(ABC, ClientQueueMixin):
+    'Abstracts socket connection, loop over it, and handling messages from it.'
+    conn: Optional[IrcConnection] = None
+    _cls_conn: type[IrcConnection] = IrcConnection
+
+    def __init__(self, conn_setup: IrcConnSetup, **kwargs) -> None:
+        self.client_id = conn_setup.hostname
+        super().__init__(client_id=self.client_id, **kwargs)
+        self.db = _ClientDb(on_update=self._on_update)
+        self.db.clear()
+        self.caps = _CapsManager(self.send, self.db.caps)
+        for k in conn_setup.__annotations__:
+            setattr(self.db, k, getattr(conn_setup, k))
+        if self.db.port <= 0:
+            self.db.port = PORT_SSL
+        if not self.db.user_wanted:
+            self.db.user_wanted = getuser()
+
+    def connect(self) -> None:
+        'Attempt to open connection, on success perform session init steps.'
+        self.db.connection_state = 'connecting'
+
+        def connect(self) -> None:
+            try:
+                self.conn = self._cls_conn(
+                    hostname=self.db.hostname, port=self.db.port,
+                    _q_out=self._q_out, client_id=self.client_id)
+            except IrcConnAbortException as e:
+                self.db.connection_state = f'failed to connect: {e}'
+            except Exception as e:  # pylint: disable=broad-exception-caught
+                self._put(ExceptionEvent(CrashingException(e)))
+            else:
+                self.db.connection_state = 'connected'
+                self.caps.start_negotation()
+                self.send('USER', self.db.user_wanted,
+                          '0', '*', self.db.realname)
+                self.send('NICK', self.db.nick_wanted,)
+
+        # Do this in a thread, not to block flow of other (e.g. TUI) events.
+        Thread(target=connect, daemon=True, args=(self,)).start()
+
+    def close(self) -> None:
+        'Close connection and wipe memory of its states.'
+        self.db.clear()
+        if self.conn:
+            self.conn.close()
+        self.conn = None
+
+    def on_handled_loop_exception(self, e: IrcConnAbortException) -> None:
+        'Gracefully handle broken connection.'
+        self.db.connection_state = f'broken: {e}'
+        self.close()
+
+    @abstractmethod
+    def _on_update(self, *path) -> None:
+        pass
+
+    @abstractmethod
+    def _alert(self, msg: str) -> None:
+        pass
+
+    def send(self, verb: str, *args) -> IrcMessage:
+        'Send msg over socket, on success log .raw.'
+        if not self.conn:
+            raise SendFail('cannot send, connection seems closed')
+        msg = IrcMessage(verb, args)
+        self.conn.send(msg)
+        return msg
+
+    def handle_msg(self, msg: IrcMessage) -> None:
+        'Log msg.raw, then process incoming msg into appropriate client steps.'
+        ret = {}
+        for ex in [ex for ex in MSG_EXPECTATIONS if ex.verb == msg.verb]:
+            result = ex.parse_msg(
+                    msg=msg,
+                    is_chan_name=self.db.is_chan_name,
+                    is_nick=self.db.is_nick,
+                    possible_nickuserhost=_NickUserHost.possible_from,
+                    into_nickuserhost=_NickUserHost.from_str)
+            if result is not None:
+                ret = result
+                break
+        if '_verb' not in ret:
+            raise ImplementationFail(f'No handler implemented for: {msg.raw}')
+        for n_u_h in ret['_nickuserhosts']:  # update, turn into proper users
+            if (id_ := self.db.users.id_for_nickuserhost(
+                    n_u_h, allow_none=True, updating=True)):
+                for ret_name in [k for k in ret if ret[k] is n_u_h]:
+                    ret[ret_name] = self.db.users[id_]
+        for verb in ('setattr', 'do', 'doafter'):
+            for task, tok_names in [t for t in ret['_tasks'].items()
+                                    if t[0].verb == verb]:
+                node = self
+                for step in task.path:
+                    key = ret[step] if step.isupper() else step
+                    node = (node[key] if isinstance(node, Dict)
+                            else (node(key) if callable(node)
+                                  else getattr(node, key)))
+                for tok_name in tok_names:
+                    if task.verb == 'setattr':
+                        setattr(node, tok_name, ret[tok_name])
+                    else:
+                        getattr(node, tok_name)()
+        if ret['_verb'] == '005':   # RPL_ISUPPORT
+            for item in ret['isupport']:
+                if item[0] == '-':
+                    del self.db.isupport[item[1:]]
+                else:
+                    key, data = _Dict.key_val_from_eq_str(item)
+                    self.db.isupport[key] = data
+        elif ret['_verb'] == '353':  # RPL_NAMREPLY
+            self.db.channels[ret['channel']].add_from_namreply(ret['names'])
+        elif ret['_verb'] == '372':  # RPL_MOTD
+            self.db.motd.append(ret['line'])
+        elif ret['_verb'] == '401':  # ERR_NOSUCHNICK
+            raise TargetUserOffline(ret['missing'])
+        elif ret['_verb'] == '432':  # ERR_ERRONEOUSNICKNAME
+            alert = 'nickname refused for bad format'
+            if 'nick' not in ret:
+                alert += ', giving up'
+                self.close()
+            self._alert(alert)
+        elif ret['_verb'] == '433':  # ERR_NICKNAMEINUSE
+            self._alert('nickname already in use, trying increment')
+            self.send('NICK', _NickUserHost(nick=ret['used']).incremented)
+        elif ret['_verb'] == 'AUTHENTICATE':
+            auth = b64encode((self.db.nick_wanted + '\0'
+                              + self.db.nick_wanted + '\0'
+                              + self.db.password
+                              ).encode('utf-8')).decode('utf-8')
+            self.send('AUTHENTICATE', auth)
+        elif ret['_verb'] == 'CAP':
+            if (self.caps.process_msg(verb=ret['subverb'], items=ret['items'],
+                                      complete='tbc' not in ret)
+                    and 'sasl' in self.db.caps.keys()
+                    and 'PLAIN' in self.db.caps['sasl'].data.split(',')):
+                if self.db.password:
+                    self.db.sasl_auth_state = 'attempting'
+                    self.send('AUTHENTICATE', 'PLAIN')
+                else:
+                    self.caps.end_negotiation()
+        elif ret['_verb'] == 'JOIN' and ret['joiner'] != self.db.users['me']:
+            self.db.channels[ret['channel']].add_user(ret['joiner'])
+        elif ret['_verb'] == 'NICK':
+            user_id = self.db.users.id_for_nickuserhost(ret['named'],
+                                                        updating=True)
+            assert user_id is not None
+            self.db.users[user_id].nick = ret['nick']
+            if user_id == 'me':
+                self.db.nick_wanted = ret['nick']
+        elif ret['_verb'] == 'PART':
+            ret['parter'].part(ret['channel'], ret.get('message', ''))
+            if ret['parter'] is self.db.users['me']:
+                del self.db.channels[ret['channel']]
+                self.db.users.purge()
+        elif ret['_verb'] == 'PING':
+            self.send('PONG', ret['reply'])
+        elif ret['_verb'] == 'QUIT':
+            ret['quitter'].quit(ret['message'])
+
+
+ClientsDb = dict[str, Client]
+
+
+@dataclass
+class NewClientEvent(AffectiveEvent):
+    'Put Client .payload into ClientsDb target.'
+    payload: 'Client'
+
+    def affect(self, target: ClientsDb) -> None:
+        target[self.payload.client_id] = self.payload
+        # only run _after_ spot in ClientsDb secure, for ClientEvents to target
+        self.payload.connect()
+
+
+@dataclass
+class ClientEvent(AffectiveEvent, _ClientIdMixin):
+    'To affect Client identified by ClientIdMixin.'
diff --git a/src/ircplom/client_tui.py b/src/ircplom/client_tui.py
new file mode 100644 (file)
index 0000000..dbdd498
--- /dev/null
@@ -0,0 +1,544 @@
+'TUI adaptions to Client.'
+# built-ins
+from enum import Enum, auto
+from getpass import getuser
+from pathlib import Path
+from typing import Any, Callable, Optional, Sequence
+# ourselves
+from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
+                              CMD_SHORTCUTS)
+from ircplom.client import (
+    AutoAttrMixin, Channel, ChatMessage, Client, ClientQueueMixin, Dict,
+    DictItem, ImplementationFail, IrcConnSetup, NewClientEvent, NickUserHost,
+    SendFail, ServerCapability, SharedClientDbFields, TargetUserOffline, User)
+from ircplom.irc_conn import IrcMessage
+
+CMD_SHORTCUTS['disconnect'] = 'window.disconnect'
+CMD_SHORTCUTS['join'] = 'window.join'
+CMD_SHORTCUTS['part'] = 'window.part'
+CMD_SHORTCUTS['nick'] = 'window.nick'
+CMD_SHORTCUTS['privmsg'] = 'window.privmsg'
+CMD_SHORTCUTS['reconnect'] = 'window.reconnect'
+CMD_SHORTCUTS['raw'] = 'window.raw'
+
+_LOG_PREFIX_SERVER = '$'
+_LOG_PREFIX_OUT = '>'
+_LOG_PREFIX_IN = '<'
+
+_PATH_LOGS = Path.home().joinpath('.local', 'share', 'ircplom', 'logs')
+
+
+class _LogScope(Enum):
+    'Where log messages should go.'
+    ALL = auto()
+    DEBUG = auto()
+    CHAT = auto()
+    USER = auto()
+    USER_NO_CHANNELS = auto()
+
+
+class _ClientWindow(Window, ClientQueueMixin):
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._title = f'{self.client_id} :DEBUG'
+
+    def log(self, msg: str) -> None:
+        super().log(msg)
+        ldir = _PATH_LOGS.joinpath(self._title)
+        if not ldir.exists():
+            ldir.mkdir(parents=True)
+        assert ldir.is_dir()
+        with ldir.joinpath(f'{self._last_today}.txt'
+                           ).open('a', encoding='utf8') as f:
+            f.write(msg + '\n')
+
+    def _send_msg(self, verb: str, params: tuple[str, ...]) -> None:
+        self._client_trigger('send_w_params_tuple', verb=verb, params=params)
+
+    def cmd__disconnect(self, quit_msg: str = 'ircplom says bye') -> None:
+        'Send QUIT command to server.'
+        self._send_msg('QUIT', (quit_msg,))
+
+    def cmd__reconnect(self) -> None:
+        'Attempt reconnection.'
+        self._client_trigger('reconnect')
+
+    def cmd__nick(self, new_nick: str) -> None:
+        'Attempt nickname change.'
+        self._send_msg('NICK', (new_nick,))
+
+    def cmd__join(self, channel: str) -> None:
+        'Attempt joining a channel.'
+        self._send_msg('JOIN', (channel,))
+
+    def cmd__privmsg(self, target: str, msg: str) -> None:
+        'Send chat message msg to target.'
+        self._client_trigger('privmsg', chat_target=target, msg=msg)
+
+    def cmd__raw(self, verb: str, params_str: str = '') -> None:
+        'Send raw command, with direct input of params string.'
+        if params_str[:1] == ':':
+            params = [params_str]
+        else:
+            params = params_str.split(' :', maxsplit=1)
+            params = params[0].split() + params[1:2]
+        self._send_msg(verb, tuple(params))
+
+
+class _ChatPrompt(PromptWidget):
+    _nickname: str = ''
+
+    @property
+    def prefix(self) -> str:
+        return f'[{self._nickname}] '
+
+    def set_prefix_data(self, nick: str) -> None:
+        'Update prompt prefix with nickname data.'
+        if nick != self._nickname:
+            self._tainted = True
+            self._nickname = nick
+
+    def enter(self) -> str:
+        to_return = super().enter()
+        if (not to_return) or to_return[0:1] == '/':
+            return to_return
+        return f'/window.chat {to_return}'
+
+
+class _ChatWindow(_ClientWindow):
+    prompt: _ChatPrompt
+
+    def __init__(self, chatname: str, get_nick_data: Callable, **kwargs
+                 ) -> None:
+        self.chatname = chatname
+        self._get_nick_data = get_nick_data
+        super().__init__(**kwargs)
+        self._title = f'{self.client_id} {self.chatname}'
+        self.set_prompt_prefix()
+
+    def set_prompt_prefix(self) -> None:
+        'Look up relevant DB data to update prompt prefix.'
+        self.prompt.set_prefix_data(self._get_nick_data())
+
+    def cmd__chat(self, msg: str) -> None:
+        'PRIVMSG to target identified by .chatname.'
+        self.cmd__privmsg(target=self.chatname, msg=msg)
+
+
+class _ChannelWindow(_ChatWindow):
+
+    def cmd__join(self, channel='') -> None:
+        super().cmd__join(channel if channel else self.chatname)
+
+    def cmd__part(self) -> None:
+        'Attempt parting channel.'
+        self._send_msg('PART', (self.chatname,))
+
+
+class _QueryWindow(_ChatWindow):
+    pass
+
+
+class _Update:
+    old_value: Any
+    results: list[tuple[_LogScope, Any]]
+
+    def __init__(self, path: tuple[str, ...], value: Any) -> None:
+        self.full_path = path
+        self.rel_path = self.full_path[:]
+        self.value = value
+        self.old_value = None
+        self.force_log = False
+        self.results = []
+
+    @property
+    def key(self) -> str:
+        'Name of item or attribute to be processed.'
+        return self.rel_path[0]
+
+    def decrement_path(self) -> None:
+        'Remove first element from .rel_path.'
+        self.rel_path = self.rel_path[1:]
+
+
+class _UpdatingNode(AutoAttrMixin):
+
+    def _make_attr(self, cls: Callable, key: str):
+        return cls()
+
+    def recursive_set_and_report_change(self, update: _Update) -> None:
+        'Apply update, and, if it makes a difference, add to its .results.'
+        update.force_log = update.force_log or (not self._is_set(update.key))
+        node = self._get(update.key)
+        if len(update.rel_path) > 1:
+            update.decrement_path()
+            node.recursive_set_and_report_change(update)
+            return
+        update.old_value = node
+        do_report = update.force_log
+        if update.value is None:
+            if self._is_set(update.key):
+                self._unset(update.key)
+                do_report |= True
+        elif update.old_value != update.value:
+            self._set(update.key, update.value)
+            do_report |= True
+        if (not do_report) or update.full_path == ('message',):
+            return
+        result = (tuple(sorted(update.value)) if isinstance(update.value, set)
+                  else update.value)
+        announcement = ':' + ':'.join(update.full_path) + ' '
+        if result is None:
+            announcement += 'cleared'
+        else:
+            announcement += 'set to:'
+            if not isinstance(result, tuple):
+                announcement += f' [{result}]'
+        scope = _LogScope.DEBUG
+        update.results += [(scope, [announcement])]
+        if isinstance(result, tuple):
+            update.results += [(scope, [f':  {item}']) for item in result]
+
+    def _get(self, key: str) -> Any:
+        return getattr(self, key)
+
+    def _set(self, key: str, value) -> None:
+        setattr(self, key, value)
+
+    def _unset(self, key: str) -> None:
+        getattr(self, key).clear()
+
+    def _is_set(self, key: str) -> bool:
+        return hasattr(self, key)
+
+
+class _UpdatingDict(Dict[DictItem], _UpdatingNode):
+
+    def items(self) -> tuple[tuple[str, DictItem], ...]:
+        'Key-value pairs of item registrations.'
+        return tuple((k, v) for k, v in self._dict.items())
+
+    def _get(self, key: str):
+        if key not in self._dict:
+            self._dict[key] = self._item_cls()
+        return self._dict[key]
+
+    def _set(self, key: str, value) -> None:
+        self._dict[key] = value
+
+    def _unset(self, key: str) -> None:
+        del self._dict[key]
+
+    def _is_set(self, key: str) -> bool:
+        return key in self._dict
+
+
+class _UpdatingChannel(_UpdatingNode, Channel):
+    user_ids: set[str]
+    exits: _UpdatingDict[str]
+
+    def recursive_set_and_report_change(self, update: _Update) -> None:
+        super().recursive_set_and_report_change(update)
+        if update.key == 'topic':
+            msg = f':{self.topic.who} set topic: {self.topic.what}'
+            update.results += [(_LogScope.CHAT, [msg])]
+        elif update.key == 'user_ids':
+            if not update.old_value:
+                nicks = []
+                for id_ in sorted(update.value):
+                    nicks += [f'NICK:{id_}', ':, ']
+                nicks.pop()
+                update.results += [(_LogScope.CHAT, [':residents: '] + nicks)]
+            else:
+                for id_ in (id_ for id_ in update.value
+                            if id_ not in update.old_value):
+                    update.results += [(_LogScope.CHAT,
+                                        [f'NUH:{id_}', ': joins'])]
+                for id_ in (id_ for id_ in update.old_value
+                            if id_ not in update.value):
+                    update.results += [(_LogScope.CHAT,
+                                        _UpdatingUser.exit_msg_toks(
+                                            f'NUH:{id_}', self.exits[id_]))]
+
+
+class _UpdatingUser(_UpdatingNode, User):
+    prev_nick = '?'
+
+    @staticmethod
+    def exit_msg_toks(tok_who: str, exit_code: str) -> list[str]:
+        'Construct part/quit message from user identifier, exit_code.'
+        verb = 'quits' if exit_code[0] == 'Q' else 'parts'
+        exit_msg = exit_code[1:]
+        msg_toks = [tok_who, f': {verb}']
+        if exit_msg:
+            msg_toks += [f':: {exit_msg}']
+        return msg_toks
+
+    def recursive_set_and_report_change(self, update: _Update) -> None:
+        super().recursive_set_and_report_change(update)
+        if update.key in {'nick', 'exit_msg'}:
+            if update.key == 'nick':
+                self.prev_nick = update.old_value
+                if update.old_value != '?':
+                    update.results += [
+                        (_LogScope.USER,
+                         [f':{self.prev} renames {update.value}'])]
+            elif update.key == 'exit_msg':
+                if update.value:
+                    update.results += [(_LogScope.USER_NO_CHANNELS,
+                                        self.exit_msg_toks(
+                                            f':{self}', update.value))]
+
+    @property
+    def prev(self) -> str:
+        'Return .nickuserhost with .prev_nick as .nick.'
+        return str(NickUserHost(self.prev_nick, self.user, self.host))
+
+
+class _UpdatingServerCapability(_UpdatingNode, ServerCapability):
+    pass
+
+
+class _TuiClientDb(_UpdatingNode, SharedClientDbFields):
+    caps: _UpdatingDict[_UpdatingServerCapability]
+    isupport: _UpdatingDict[str]
+    motd: tuple[str, ...] = tuple()
+    users: _UpdatingDict[_UpdatingUser]
+    channels: _UpdatingDict[_UpdatingChannel]
+
+    def recursive_set_and_report_change(self, update: _Update) -> None:
+        super().recursive_set_and_report_change(update)
+        if update.key == 'connection_state':
+            if update.value == 'connected':
+                update.results += [(_LogScope.ALL, [':CONNECTED'])]
+            elif not update.value:
+                update.results += [(_LogScope.ALL, [':DISCONNECTED'])]
+        elif update.key == 'message' and update.value:
+            assert isinstance(update.value, ChatMessage)
+            toks = [':*** '] if update.value.is_notice else []
+            toks += [':[']
+            toks += [f':{update.value.sender}' if update.value.sender
+                     else 'NICK:me']
+            toks += [f':] {update.value.content}']
+            update.results += [(_LogScope.CHAT, toks)]
+
+
+class _ClientWindowsManager:
+
+    def __init__(self, tui_log: Callable, tui_new_window: Callable) -> None:
+        self._tui_log = tui_log
+        self._tui_new_window = tui_new_window
+        self.db = _TuiClientDb()
+        self.windows: list[_ClientWindow] = []
+
+    def _new_win(self, scope: _LogScope, chatname: str = '') -> _ClientWindow:
+        if scope == _LogScope.CHAT:
+            win = self._tui_new_window(
+                    win_cls=(_ChannelWindow if self.db.is_chan_name(chatname)
+                             else _QueryWindow),
+                    chatname=chatname,
+                    get_nick_data=lambda: (self.db.users['me'].nick
+                                           if 'me' in self.db.users.keys()
+                                           else '?'))
+        else:
+            win = self._tui_new_window(win_cls=_ClientWindow)
+        self.windows += [win]
+        return win
+
+    def windows_for(self, scope: _LogScope, id_='') -> list[_ClientWindow]:
+        'Return client windows of scope, and additional potential identifier.'
+        ret = []
+        if scope == _LogScope.ALL:
+            ret = [w for w in self.windows
+                   if w not in self.windows_for(_LogScope.DEBUG)]
+        elif scope == _LogScope.DEBUG:
+            ret = [w for w in self.windows if not isinstance(w, _ChatWindow)]
+        elif scope == _LogScope.CHAT:
+            ret = [w for w in self.windows
+                   if isinstance(w, _ChatWindow) and w.chatname == id_]
+        elif scope == _LogScope.USER:
+            chan_names = [c for c, v in self.db.channels.items()
+                          if id_ in v.user_ids]
+            ret = [w for w in self.windows
+                   if (isinstance(w, _ChannelWindow)
+                       and w.chatname in chan_names)
+                   or (isinstance(w, _QueryWindow)
+                       and (id_ == 'me' or w.chatname in {
+                           self.db.users[id_].nick,
+                           self.db.users[id_].prev_nick}))]
+        elif scope == _LogScope.USER_NO_CHANNELS:
+            ret = [w for w in self.windows_for(_LogScope.USER, id_)
+                   if isinstance(w, _QueryWindow)]
+        if (not ret) and scope in {_LogScope.CHAT, _LogScope.DEBUG}:
+            ret += [self._new_win(scope, id_)]
+        ret.sort(key=lambda w: w.idx)
+        return ret
+
+    def log(self,
+            msg: str,
+            scope: _LogScope,
+            alert=False,
+            target='',
+            out: Optional[bool] = None
+            ) -> None:
+        'From parsing scope, kwargs, build prefix before sending to logger.'
+        prefix = '$'
+        if out is not None:
+            prefix = _LOG_PREFIX_OUT if out else _LOG_PREFIX_IN
+        kwargs = {'alert': True} if alert else {}
+        kwargs |= {'target': target} if target else {}
+        self._tui_log(msg, scope=scope, prefix=prefix, **kwargs)
+
+    def update_db(self, update: _Update) -> bool:
+        'Apply update to .db, and if changing anything, log and trigger.'
+        self.db.recursive_set_and_report_change(update)
+        if not update.results:
+            return False
+        for scope, result in update.results:
+            msg = ''
+            for item in result:
+                transform, content = item.split(':', maxsplit=1)
+                if transform in {'NICK', 'NUH'}:
+                    nuh = self.db.users[content]
+                    content = str(nuh) if transform == 'NUH' else nuh.nick
+                msg += content
+            out: Optional[bool] = None
+            target = ''
+            if update.full_path == ('message',):
+                target = update.value.target or update.value.sender
+                out = not bool(update.value.sender)
+            elif scope in {_LogScope.CHAT, _LogScope.USER,
+                           _LogScope.USER_NO_CHANNELS}:
+                target = update.full_path[1]
+            self.log(msg, scope=scope, target=target, out=out)
+        for win in [w for w in self.windows if isinstance(w, _ChatWindow)]:
+            win.set_prompt_prefix()
+        return bool([w for w in self.windows if w.tainted])
+
+
+class ClientTui(BaseTui):
+    'TUI expanded towards Client features.'
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._client_mngrs: dict[str, _ClientWindowsManager] = {}
+
+    def _log_target_wins(self, **kwargs) -> Sequence[Window]:
+        if (scope := kwargs.get('scope', None)):
+            return self._client_mngrs[kwargs['client_id']].windows_for(
+                    scope, kwargs.get('target', ''))
+        return super()._log_target_wins(**kwargs)
+
+    def for_client_do(self, client_id: str, todo: str, **kwargs) -> None:
+        'Forward todo to appropriate _ClientWindowsManager.'
+        if client_id not in self._client_mngrs:
+            self._client_mngrs[client_id] = _ClientWindowsManager(
+                tui_log=lambda msg, **kw: self.log(
+                    msg, client_id=client_id, **kw),
+                tui_new_window=lambda win_cls, **kw: self._new_window(
+                    win_cls, _q_out=self._q_out, client_id=client_id, **kw))
+        if getattr(self._client_mngrs[client_id], todo)(**kwargs) is not False:
+            self.redraw_affected()
+
+    def _new_client(self, conn_setup: IrcConnSetup) -> 'ClientKnowingTui':
+        return ClientKnowingTui(_q_out=self._q_out, conn_setup=conn_setup)
+
+    def cmd__connect(self,
+                     host_port: str,
+                     nickname_pw: str = '',
+                     username_realname: str = ''
+                     ) -> Optional[str]:
+        'Create Client and pass it via NewClientEvent.'
+        split = host_port.split(':', maxsplit=1)
+        hostname = split[0]
+        if hostname in self._client_mngrs:
+            return f'already set up connection to {hostname}'
+        port = -1
+        if len(split) > 1:
+            to_int = split[1]
+            if to_int.isdigit():
+                port = int(split[1])
+            else:
+                return f'invalid port number: {to_int}'
+        split = nickname_pw.split(':', maxsplit=1)
+        nickname = split[0] if nickname_pw else getuser()
+        password = split[1] if len(split) > 1 else ''
+        if not username_realname:
+            username = ''
+            realname = nickname
+        elif ':' in username_realname:
+            username, realname = username_realname.split(':', maxsplit=1)
+        else:
+            username = ''
+            realname = username_realname
+        self._put(NewClientEvent(self._new_client(IrcConnSetup(
+            hostname, port, nickname, username, realname, password))))
+        return None
+
+
+class ClientKnowingTui(Client):
+    'Adapted to communicate with ClientTui.'
+
+    def _tui_trigger(self, method_name: str, **kwargs) -> None:
+        self._put(TuiEvent.affector(method_name).kw(**kwargs))
+
+    def _tui_alert_trigger(self, msg: str) -> None:
+        self._tui_trigger('log', msg=msg, prefix=_LOG_PREFIX_SERVER,
+                          alert=True)
+
+    def _client_tui_trigger(self, todo: str, **kwargs) -> None:
+        self._tui_trigger('for_client_do', client_id=self.client_id,
+                          todo=todo, **kwargs)
+
+    def send_w_params_tuple(self, verb: str, params: tuple[str, ...]) -> None:
+        'Helper for ClientWindow to trigger .send, for it can only do kwargs.'
+        self.send(verb, *params)
+
+    def privmsg(self, chat_target: str, msg: str) -> None:
+        'Catch /privmsg, only allow for channel if in channel, else complain.'
+        try:
+            if self.db.is_chan_name(chat_target)\
+                    and chat_target not in self.db.channels.keys():
+                raise SendFail('not sending, since not in channel')
+            self.send('PRIVMSG', chat_target, msg)
+        except SendFail as e:
+            self._tui_alert_trigger(f'{e}')
+        else:
+            self.db.messaging('').to(chat_target).privmsg = msg  # type: ignore
+
+    def reconnect(self) -> None:
+        'Catch /reconnect, only initiate if not connected, else complain back.'
+        if self.conn:
+            self._tui_alert_trigger(
+                    'not re-connecting since already connected')
+            return
+        self.connect()
+
+    def send(self, verb: str, *args) -> IrcMessage:
+        msg = super().send(verb, *args)
+        self._log(msg.raw, out=True)
+        return msg
+
+    def handle_msg(self, msg: IrcMessage) -> None:
+        self._log(msg.raw, out=False)
+        try:
+            super().handle_msg(msg)
+        except ImplementationFail as e:
+            self._alert(str(e))
+        except TargetUserOffline as e:
+            name = f'{e}'
+            self._log(f'{name} not online', target=name, alert=True)
+
+    def _alert(self, msg: str) -> None:
+        self._log(msg, alert=True)
+
+    def _log(self, msg: str, alert=False, target='', out: Optional[bool] = None
+             ) -> None:
+        scope = _LogScope.CHAT if target else _LogScope.DEBUG
+        self._client_tui_trigger('log', scope=scope, msg=msg, alert=alert,
+                                 target=target, out=out)
+
+    def _on_update(self, *path) -> None:
+        for path, value in self.db.into_endnode_updates(path):
+            self._client_tui_trigger('update_db', update=_Update(path, value))
diff --git a/src/ircplom/events.py b/src/ircplom/events.py
new file mode 100644 (file)
index 0000000..9dac06b
--- /dev/null
@@ -0,0 +1,110 @@
+'Event system with event loop.'
+from abc import abstractmethod, ABC
+from dataclasses import dataclass
+from queue import SimpleQueue, Empty as QueueEmpty
+from threading import Thread
+from typing import Any, Iterator, Literal, Self
+
+
+class Event:  # pylint: disable=too-few-public-methods
+    'Communication unit between threads.'
+
+
+QuitEvent = type('QuitEvent', (Event,), {})
+
+
+class AffectiveEvent(Event, ABC):
+    'For Events that are to affect other objects.'
+
+    @abstractmethod
+    def affect(self, target: Any) -> None:
+        'To be run by main loop on target.'
+
+    @classmethod
+    def affector(cls, t_method: str, **kwargs):
+        '''Return instance of subclass affecting target via t_method.
+
+        This will often be more convenient than a full subclass definition,
+        which mostly only matters for what the main loop gotta differentiate.
+        '''
+        def wrap():  # else current mypy throw [valid-type] on 'Variable "cls"'
+            class _Affector(cls):
+                def __init__(self, t_method: str, **kwargs) -> None:
+                    super().__init__(**kwargs)
+                    self.t_method = t_method
+                    self.kwargs: dict[str, Any] = {}
+
+                def kw(self, **kwargs) -> Self:
+                    'Collect .kwargs expanded, return self for chaining.'
+                    for k, v in kwargs.items():
+                        self.kwargs[k] = v
+                    return self
+
+                def affect(self, target) -> None:
+                    'Call target.t_method(**.kwargs).'
+                    getattr(target, self.t_method)(**self.kwargs)
+
+            return _Affector
+
+        return wrap()(t_method=t_method, **kwargs)
+
+
+class CrashingException(BaseException):
+    'To explicitly crash, because it should never happen, but explaining why.'
+
+
+@dataclass
+class ExceptionEvent(Event):
+    'To deliver Exception to main loop for handling.'
+    exception: CrashingException
+
+
+@dataclass
+class QueueMixin:
+    'Adds SimpleQueue addressable via ._put(Event).'
+    _q_out: SimpleQueue
+
+    def _put(self, event: Event) -> None:
+        self._q_out.put(event)
+
+
+class Loop(QueueMixin):
+    'Wraps thread looping over iterator, communicating back via q_out.'
+
+    def __init__(self, iterator: Iterator, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._q_quit: SimpleQueue = SimpleQueue()
+        self._iterator = iterator
+        self._thread = Thread(target=self._loop, daemon=False)
+        self._thread.start()
+
+    def stop(self) -> None:
+        'Break threaded loop, but wait for it to finish properly.'
+        self._q_quit.put(None)
+        self._thread.join()
+
+    def __enter__(self) -> Self:
+        return self
+
+    def __exit__(self, *_) -> Literal[False]:
+        self.stop()
+        return False  # re-raise any exception that above ignored
+
+    def _loop(self) -> None:
+        try:
+            while True:
+                try:
+                    self._q_quit.get(block=True, timeout=0)
+                except QueueEmpty:
+                    pass
+                else:
+                    break
+                try:
+                    it_yield = next(self._iterator)
+                except StopIteration:
+                    break
+                if it_yield is not None:
+                    self._put(it_yield)
+        # catch _all_ just so they exit readably with the main loop
+        except Exception as e:  # pylint: disable=broad-exception-caught
+            self._put(ExceptionEvent(CrashingException(e)))
diff --git a/src/ircplom/irc_conn.py b/src/ircplom/irc_conn.py
new file mode 100644 (file)
index 0000000..fb1bd21
--- /dev/null
@@ -0,0 +1,207 @@
+'Low-level IRC protocol / server connection management.'
+# built-ins
+from abc import ABC, abstractmethod
+from socket import socket, gaierror as socket_gaierror
+from ssl import create_default_context as create_ssl_context
+from typing import Callable, Iterator, NamedTuple, Optional, Self
+# ourselves
+from ircplom.events import Event, Loop, QueueMixin
+
+
+PORT_SSL = 6697
+_TIMEOUT_RECV_LOOP = 0.1
+_TIMEOUT_CONNECT = 5
+_CONN_RECV_BUFSIZE = 1024
+
+ILLEGAL_NICK_CHARS = ' ,*?!@'
+ILLEGAL_NICK_FIRSTCHARS = ':$'
+ISUPPORT_DEFAULTS = {
+    'CHANTYPES': '#&',
+    'PREFIX': '(ov)@+',
+    'USERLEN': '10'
+}
+_IRCSPEC_LINE_SEPARATOR = b'\r\n'
+_IRCSPEC_TAG_ESCAPES = ((r'\:', ';'),
+                        (r'\s', ' '),
+                        (r'\n', '\n'),
+                        (r'\r', '\r'),
+                        (r'\\', '\\'))
+
+
+class IrcMessage:
+    'Properly structured representation of IRC message as per IRCv3 spec.'
+    _raw: Optional[str] = None
+
+    def __init__(self,
+                 verb: str,
+                 params: Optional[tuple[str, ...]] = None,
+                 source: str = '',
+                 tags: Optional[dict[str, str]] = None
+                 ) -> None:
+        self.verb: str = verb
+        self.params: tuple[str, ...] = params or tuple()
+        self.source: str = source
+        self.tags: dict[str, str] = tags or {}
+
+    @classmethod
+    def from_raw(cls, raw_msg: str) -> Self:
+        'Parse raw IRC message line into properly structured IrcMessage.'
+
+        class _Stage(NamedTuple):
+            name: str
+            prefix_char: Optional[str]
+            processor: Callable = lambda s: s
+
+        def _parse_tags(str_tags: str) -> dict[str, str]:
+            tags = {}
+            for str_tag in [s for s in str_tags.split(';') if s]:
+                if '=' in str_tag:
+                    key, val = str_tag.split('=', maxsplit=1)
+                    for to_repl, repl_with in _IRCSPEC_TAG_ESCAPES:
+                        val = val.replace(to_repl, repl_with)
+                else:
+                    key, val = str_tag, ''
+                tags[key] = val
+            return tags
+
+        def _split_params(str_params: str) -> tuple[str, ...]:
+            params = []
+            params_stage = 0  # 0: gap, 1: non-trailing, 2: trailing
+            for char in str_params:
+                if char == ' ' and params_stage < 2:
+                    params_stage = 0
+                    continue
+                if params_stage == 0:
+                    params += ['']
+                    params_stage += 1
+                    if char == ':':
+                        params_stage += 1
+                        continue
+                params[-1] += char
+            return tuple(p for p in params)
+
+        stages = [_Stage('tags', '@', _parse_tags),
+                  _Stage('source', ':'),
+                  _Stage('verb', None, lambda s: s.upper()),
+                  _Stage('params', None, _split_params)]
+        harvest = {s.name: '' for s in stages}
+        idx_stage = -1
+        stage = None
+        for char in raw_msg:
+            if char == ' ' and idx_stage < (len(stages) - 1):
+                if stage:
+                    stage = None
+                continue
+            if not stage:
+                while not stage:
+                    idx_stage += 1
+                    tested = stages[idx_stage]
+                    if (not tested.prefix_char) or char == tested.prefix_char:
+                        stage = tested
+                if stage.prefix_char:
+                    continue
+            harvest[stage.name] += char
+        msg = cls(**{s.name: s.processor(harvest[s.name]) for s in stages})
+        msg._raw = raw_msg
+        return msg
+
+    @property
+    def raw(self) -> str:
+        'Return raw message code – create from known fields if necessary.'
+        if not self._raw:
+            to_combine = []
+            if self.tags:
+                tag_strs = []
+                for key, val in self.tags.items():
+                    tag_strs += [key]
+                    if not val:
+                        continue
+                    for repl_with, to_repl in reversed(_IRCSPEC_TAG_ESCAPES):
+                        val = val.replace(to_repl, repl_with)
+                    tag_strs[-1] += f'={val}'
+                to_combine += ['@' + ';'.join(tag_strs)]
+            to_combine += [self.verb]
+            if self.params:
+                to_combine += self.params[:-1]
+                to_combine += [f':{self.params[-1]}']
+            self._raw = ' '.join(to_combine)
+        return self._raw
+
+
+class IrcConnAbortException(BaseException):
+    'Thrown by BaseIrcConnection on expectable connection failures.'
+
+
+class BaseIrcConnection(QueueMixin, ABC):
+    'Collects low-level server-client connection management.'
+
+    def __init__(self, hostname: str, port: int, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.ssl = port == PORT_SSL
+        self._set_up_socket(hostname, port)
+        self._recv_loop = Loop(iterator=self._read_lines(), _q_out=self._q_out)
+
+    def _set_up_socket(self, hostname: str, port: int) -> None:
+        self._socket = socket()
+        if self.ssl:
+            self._socket = create_ssl_context().wrap_socket(
+                self._socket, server_hostname=hostname)
+        self._socket.settimeout(_TIMEOUT_CONNECT)
+        try:
+            self._socket.connect((hostname, port))
+        except (TimeoutError, socket_gaierror) as e:
+            raise IrcConnAbortException(e) from e
+        self._socket.settimeout(_TIMEOUT_RECV_LOOP)
+
+    def close(self) -> None:
+        'Stop recv loop and close socket.'
+        self._recv_loop.stop()
+        self._socket.close()
+
+    def send(self, msg: IrcMessage) -> None:
+        'Send line-separator-delimited message over socket.'
+        self._socket.sendall(msg.raw.encode('utf-8') + _IRCSPEC_LINE_SEPARATOR)
+
+    @abstractmethod
+    def _make_recv_event(self, msg: IrcMessage) -> Event:
+        pass
+
+    @abstractmethod
+    def _on_handled_loop_exception(self, _: IrcConnAbortException) -> Event:
+        pass
+
+    def _read_lines(self) -> Iterator[Optional[Event]]:
+        assert self._socket is not None
+        bytes_total = b''
+        buffer_linesep = b''
+        try:
+            while True:
+                try:
+                    bytes_new = self._socket.recv(_CONN_RECV_BUFSIZE)
+                except TimeoutError:
+                    yield None
+                    continue
+                except ConnectionResetError as e:
+                    raise IrcConnAbortException(e) from e
+                except OSError as e:
+                    if e.errno == 9:
+                        raise IrcConnAbortException(e) from e
+                    raise e
+                if not bytes_new:
+                    break
+                for c in bytes_new:
+                    c_byted = c.to_bytes()
+                    if c not in _IRCSPEC_LINE_SEPARATOR:
+                        bytes_total += c_byted
+                        buffer_linesep = b''
+                    elif c == _IRCSPEC_LINE_SEPARATOR[0]:
+                        buffer_linesep = c_byted
+                    else:
+                        buffer_linesep += c_byted
+                    if buffer_linesep == _IRCSPEC_LINE_SEPARATOR:
+                        buffer_linesep = b''
+                        yield self._make_recv_event(
+                            IrcMessage.from_raw(bytes_total.decode('utf-8')))
+                        bytes_total = b''
+        except IrcConnAbortException as e:
+            yield self._on_handled_loop_exception(e)
diff --git a/src/ircplom/msg_parse_expectations.py b/src/ircplom/msg_parse_expectations.py
new file mode 100644 (file)
index 0000000..84478b4
--- /dev/null
@@ -0,0 +1,560 @@
+'Structured expectations and processing hints for server messages.'
+from enum import Enum, auto
+from typing import Any, Callable, NamedTuple, Optional, Self
+from ircplom.irc_conn import IrcMessage
+
+
+class _MsgTok(Enum):
+    'Server message token classifications.'
+    ANY = auto()
+    CHANNEL = auto()
+    LIST = auto()
+    NICKNAME = auto()
+    NONE = auto()
+    SERVER = auto()
+    NICK_USER_HOST = auto()
+
+
+_MsgTokGuide = str | _MsgTok | tuple[str | _MsgTok, str]
+
+
+class _Command(NamedTuple):
+    verb: str
+    path: tuple[str, ...]
+
+    @classmethod
+    def from_(cls, input_: str) -> Self:
+        'Split by first "_" into verb, path (split into steps tuple by ".").'
+        verb, path_str = input_.split('_', maxsplit=1)
+        return cls(verb, tuple(step for step in path_str.split('.')
+                               if path_str))
+
+
+class _MsgParseExpectation:
+
+    def __init__(self,
+                 verb: str,
+                 source: _MsgTokGuide,
+                 params: tuple[_MsgTokGuide, ...] = tuple(),
+                 idx_into_list: int = -1,
+                 bonus_tasks: tuple[str, ...] = tuple()
+                 ) -> None:
+
+        class _Code(NamedTuple):
+            title: str
+            commands: tuple[_Command, ...]
+
+            @classmethod
+            def from_(cls, input_: str) -> Self:
+                'Split by ":" into commands (further split by ","), title.'
+                cmdsstr, title = input_.split(':', maxsplit=1)
+                return cls(title, tuple(_Command.from_(t)
+                                        for t in cmdsstr.split(',') if t))
+
+        class _TokExpectation(NamedTuple):
+            type_: _MsgTok | str
+            code: Optional[_Code]
+
+            @classmethod
+            def from_(cls, val: _MsgTokGuide) -> Self:
+                'Standardize value into .type_, (potentially empty) .code.'
+                t = ((val[0], _Code.from_(val[1])) if isinstance(val, tuple)
+                     else (val, None))
+                return cls(*t)
+
+        self.verb = verb
+        self.source = _TokExpectation.from_(source)
+        self.params = tuple(_TokExpectation.from_(param) for param in params)
+        self.idx_into_list = idx_into_list
+        self.bonus_tasks = tuple(_Code.from_(item) for item in bonus_tasks)
+
+    def parse_msg(self,
+                  msg: IrcMessage,
+                  is_chan_name: Callable,
+                  is_nick: Callable,
+                  possible_nickuserhost: Callable,
+                  into_nickuserhost: Callable
+                  ) -> Optional[dict[str, Any]]:
+        'Try parsing msg into informative result dictionary, or None on fail.'
+        cmp_params: list[str | tuple[str, ...]]
+        if self.idx_into_list < 0:
+            cmp_params = list(msg.params)
+        else:
+            idx_after = len(msg.params) + 1 - (len(self.params)
+                                               - self.idx_into_list)
+            cmp_params = (list(msg.params[:self.idx_into_list]) +
+                          [msg.params[self.idx_into_list:idx_after]] +
+                          list(msg.params[idx_after:]))
+        cmp_fields = tuple([msg.source] + cmp_params)
+        ex_fields = tuple([self.source] + list(self.params))
+        if len(ex_fields) != len(cmp_fields):
+            return None
+        validators: dict[_MsgTok, Callable[[Any], bool]] = {
+            _MsgTok.NONE: lambda tok: tok == '',
+            _MsgTok.CHANNEL: is_chan_name,
+            _MsgTok.NICKNAME: is_nick,
+            _MsgTok.NICK_USER_HOST: possible_nickuserhost,
+            _MsgTok.SERVER: lambda tok: '.' in tok and not set('@!') & set(tok)
+        }
+        parsers: dict[_MsgTok, Callable[[Any], Any]] = {
+            _MsgTok.LIST: lambda tok: tuple(tok.split()),
+            _MsgTok.NICK_USER_HOST: into_nickuserhost
+        }
+        parsed: dict[str, str | tuple[str, ...]] = {}
+        singled_tasks: list[tuple[_Command, str]] = []
+        nickuserhosts = []
+        for ex_tok, cmp_tok in [(ex_tok, cmp_fields[idx])
+                                for idx, ex_tok in enumerate(ex_fields)]:
+            if isinstance(ex_tok.type_, str) and ex_tok.type_ != cmp_tok:
+                return None
+            if (not isinstance(ex_tok.type_, str))\
+                    and ex_tok.type_ in validators\
+                    and not validators[ex_tok.type_](cmp_tok):
+                return None
+            if ex_tok.code or ex_tok.type_ is _MsgTok.NICK_USER_HOST:
+                value = (cmp_tok if (isinstance(ex_tok.type_, str)
+                                     or ex_tok.type_ not in parsers)
+                         else parsers[ex_tok.type_](cmp_tok))
+                if ex_tok.type_ is _MsgTok.NICK_USER_HOST:
+                    nickuserhosts += [value]
+                if ex_tok.code:
+                    parsed[ex_tok.code.title] = value
+                    singled_tasks += [(cmd, ex_tok.code.title)
+                                      for cmd in ex_tok.code.commands]
+        for code in self.bonus_tasks:
+            singled_tasks += [(cmd, code.title) for cmd in code.commands]
+        tasks: dict[_Command, list[str]] = {}
+        for cmd, title in singled_tasks:
+            if cmd not in tasks:
+                tasks[cmd] = []
+            tasks[cmd] += [title]
+        return parsed | {'_verb': self.verb, '_tasks': tasks,
+                         '_nickuserhosts': nickuserhosts}
+
+
+MSG_EXPECTATIONS: list[_MsgParseExpectation] = [
+
+    # these we ignore except for confirming/collecting the nickname
+
+    _MsgParseExpectation(
+        '001',  # RPL_WELCOME
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '002',  # RPL_YOURHOST
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '003',  # RPL_CREATED
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '004',  # RPL_MYINFO
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '250',  # RPL_STATSDLINE / RPL_STATSCONN
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '251',  # RPL_LUSERCLIENT
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '252',  # RPL_LUSEROP
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '253',  # RPL_LUSERUNKNOWN
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '254',  # RPL_LUSERCHANNELS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '255',  # RPL_LUSERME
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '265',  # RPL_LOCALUSERS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+    _MsgParseExpectation(
+        '265',  # RPL_LOCALUSERS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '266',  # RPL_GLOBALUSERS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+    _MsgParseExpectation(
+        '266',  # RPL_GLOBALUSERS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,
+         _MsgTok.ANY,
+         _MsgTok.ANY)),
+
+    _MsgParseExpectation(
+        '375',  # RPL_MOTDSTART already implied by 1st 372
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY)),
+
+    # various login stuff
+
+    _MsgParseExpectation(
+        '005',  # RPL_ISUPPORT
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':isupport'),
+         _MsgTok.ANY),  # comment
+        idx_into_list=1),
+
+    _MsgParseExpectation(
+        '372',  # RPL_MOTD
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, ':line'))),
+
+    _MsgParseExpectation(
+        '376',  # RPL_ENDOFMOTD
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY),  # comment
+        bonus_tasks=('do_db.motd:complete',)),
+
+    _MsgParseExpectation(
+        '396',  # RPL_VISIBLEHOST
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.SERVER, 'setattr_db.users.me:host'),
+         _MsgTok.ANY)),  # comment
+
+    # SASL
+
+    _MsgParseExpectation(
+        '900',  # RPL_LOGGEDIN
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
+         (_MsgTok.ANY, 'setattr_db:sasl_account'),
+         _MsgTok.ANY)),  # comment
+
+    _MsgParseExpectation(
+        '903',  # RPL_SASLSUCCESS
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
+        bonus_tasks=('do_caps:end_negotiation',)),
+
+    _MsgParseExpectation(
+        '904',  # ERR_SASLFAIL
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db:sasl_auth_state')),
+        bonus_tasks=('do_caps:end_negotiation',)),
+
+    _MsgParseExpectation(
+        'AUTHENTICATE',
+        _MsgTok.NONE,
+        ('+',)),
+
+    # capability negotation
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('NEW', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('DEL', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('ACK', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('ACK', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('NAK', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('NAK', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('LS', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('LS', ':subverb'),
+         ('*', ':tbc'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('LS', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('LS', ':subverb'),
+         ('*', ':tbc'),
+         (_MsgTok.LIST, ':items'))),
+
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('LIST', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ('*',
+         ('LIST', ':subverb'),
+         ('*', ':tbc'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('LIST', ':subverb'),
+         (_MsgTok.LIST, ':items'))),
+    _MsgParseExpectation(
+        'CAP',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         ('LIST', ':subverb'),
+         ('*', ':tbc'),
+         (_MsgTok.LIST, ':items'))),
+
+    # nickname management
+
+    _MsgParseExpectation(
+        '432',  # ERR_ERRONEOUSNICKNAME
+        _MsgTok.SERVER,
+        ('*',
+         _MsgTok.ANY,  # bad one probably fails our NICKNAME tests
+         _MsgTok.ANY)),  # comment
+    _MsgParseExpectation(
+        '432',  # ERR_ERRONEOUSNICKNAME
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         _MsgTok.ANY,  # bad one probably fails our NICKNAME tests
+         _MsgTok.ANY)),  # comment
+
+    _MsgParseExpectation(
+        '433',  # ERR_NICKNAMEINUSE
+        _MsgTok.SERVER,
+        ('*',
+         (_MsgTok.NICKNAME, ':used'),
+         _MsgTok.ANY)),  # comment
+    _MsgParseExpectation(
+        '433',  # ERR_NICKNAMEINUSE
+        _MsgTok.SERVER,
+        (_MsgTok.NICKNAME,  # we rather go for incrementation
+         (_MsgTok.NICKNAME, ':used'),
+         _MsgTok.ANY)),  # comment
+
+    _MsgParseExpectation(
+        'NICK',
+        (_MsgTok.NICK_USER_HOST, ':named'),
+        ((_MsgTok.NICKNAME, ':nick'),)),
+
+    # joining/leaving
+
+    _MsgParseExpectation(
+        '332',  # RPL_TOPIC
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what'))),
+
+    _MsgParseExpectation(
+        '333',  # RPL_TOPICWHOTIME
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
+         (_MsgTok.ANY, ':timestamp')),
+        bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)),
+
+    _MsgParseExpectation(
+        '353',  # RPL_NAMREPLY
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         '@',
+         (_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.LIST, ':names'))),
+    _MsgParseExpectation(
+        '353',  # RPL_NAMREPLY
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         '=',
+         (_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.LIST, ':names'))),
+
+    _MsgParseExpectation(
+        '366',  # RPL_ENDOFNAMES
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.CHANNEL, ':CHAN'),
+         _MsgTok.ANY),  # comment
+        bonus_tasks=('doafter_db.channels.CHAN.user_ids:complete',)),
+
+    _MsgParseExpectation(
+        'JOIN',
+        (_MsgTok.NICK_USER_HOST, ':joiner'),
+        ((_MsgTok.CHANNEL, ':channel'),)),
+
+    _MsgParseExpectation(
+        'PART',
+        (_MsgTok.NICK_USER_HOST, ':parter'),
+        ((_MsgTok.CHANNEL, ':channel'),)),
+    _MsgParseExpectation(
+        'PART',
+        (_MsgTok.NICK_USER_HOST, ':parter'),
+        ((_MsgTok.CHANNEL, ':channel'),
+         (_MsgTok.ANY, ':message'))),
+
+    # messaging
+
+    _MsgParseExpectation(
+        '401',  # ERR_NOSUCKNICK
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.NICKNAME, ':missing'),
+         _MsgTok.ANY)),  # comment
+
+    _MsgParseExpectation(
+        'NOTICE',
+        _MsgTok.SERVER,
+        ('*',
+         (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))),
+    _MsgParseExpectation(
+        'NOTICE',
+        _MsgTok.SERVER,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db.messaging. server.to.:notice'))),
+
+    _MsgParseExpectation(
+        'NOTICE',
+        (_MsgTok.NICK_USER_HOST, ':USER'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:notice'))),
+
+    _MsgParseExpectation(
+        'NOTICE',
+        (_MsgTok.NICK_USER_HOST, ':USER'),
+        ((_MsgTok.CHANNEL, ':CHANNEL'),
+         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:notice'))),
+
+    _MsgParseExpectation(
+        'PRIVMSG',
+        (_MsgTok.NICK_USER_HOST, ':USER'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.:privmsg'))),
+    _MsgParseExpectation(
+        'PRIVMSG',
+        (_MsgTok.NICK_USER_HOST, ':USER'),
+        ((_MsgTok.CHANNEL, ':CHANNEL'),
+         (_MsgTok.ANY, 'setattr_db.messaging.USER.to.CHANNEL:privmsg'))),
+
+    # misc.
+
+    _MsgParseExpectation(
+        'ERROR',
+        _MsgTok.NONE,
+        ((_MsgTok.ANY, 'setattr_db:connection_state'),),
+        bonus_tasks=('doafter_:close',)),
+
+    _MsgParseExpectation(
+        'MODE',
+        (_MsgTok.NICK_USER_HOST, 'setattr_db.users.me:nickuserhost'),
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db.users.me:modes'))),
+    _MsgParseExpectation(
+        'MODE',
+        _MsgTok.NICKNAME,
+        ((_MsgTok.NICKNAME, 'setattr_db.users.me:nick'),
+         (_MsgTok.ANY, 'setattr_db.users.me:modes'))),
+
+    _MsgParseExpectation(
+        'PING',
+        _MsgTok.NONE,
+        ((_MsgTok.ANY, ':reply'),)),
+
+    _MsgParseExpectation(
+        'TOPIC',
+        (_MsgTok.NICK_USER_HOST, 'setattr_db.channels.CHAN.topic:who'),
+        ((_MsgTok.CHANNEL, ':CHAN'),
+         (_MsgTok.ANY, 'setattr_db.channels.CHAN.topic:what')),
+        bonus_tasks=('doafter_db.channels.CHAN.topic:complete',)),
+
+    _MsgParseExpectation(
+        'QUIT',
+        (_MsgTok.NICK_USER_HOST, ':quitter'),
+        ((_MsgTok.ANY, ':message'),)),
+]
diff --git a/src/ircplom/testing.py b/src/ircplom/testing.py
new file mode 100644 (file)
index 0000000..57de470
--- /dev/null
@@ -0,0 +1,157 @@
+'Basic testing.'
+from contextlib import contextmanager
+from queue import SimpleQueue, Empty as QueueEmpty
+from pathlib import Path
+from typing import Generator, Iterator, Optional
+from ircplom.events import Event, Loop, QueueMixin
+from ircplom.client import IrcConnection, IrcConnSetup
+from ircplom.client_tui import ClientKnowingTui, ClientTui
+from ircplom.irc_conn import IrcConnAbortException, IrcMessage
+from ircplom.tui_base import TerminalInterface, TuiEvent
+
+
+_PATH_TEST_TXT = Path('test.txt')
+
+
+class TestTerminal(QueueMixin, TerminalInterface):
+    'Collects keypresses from string queue, otherwise mostly dummy.'
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._q_keypresses: SimpleQueue = SimpleQueue()
+
+    @contextmanager
+    def setup(self) -> Generator:
+        with Loop(iterator=self._get_keypresses(), _q_out=self._q_out):
+            yield self
+
+    def flush(self) -> None:
+        pass
+
+    def calc_geometry(self) -> None:
+        self.size = TerminalInterface.__annotations__['size'](0, 0)
+
+    def wrap(self, line: str) -> list[str]:
+        return []
+
+    def write(self,
+              msg: str = '',
+              start_y: Optional[int] = None,
+              attribute: Optional[str] = None,
+              padding: bool = True
+              ) -> None:
+        pass
+
+    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
+        while True:
+            try:
+                to_yield = self._q_keypresses.get(timeout=0.1)
+            except QueueEmpty:
+                yield None
+                continue
+            yield TuiEvent.affector('handle_keyboard_event'
+                                    ).kw(typed_in=to_yield)
+
+
+class _FakeIrcConnection(IrcConnection):
+
+    def __init__(self, **kwargs) -> None:
+        self._q_server_msgs: SimpleQueue = SimpleQueue()
+        super().__init__(**kwargs)
+
+    def put_server_msg(self, msg: str) -> None:
+        'Simulate message coming from server.'
+        self._q_server_msgs.put(msg)
+
+    def _set_up_socket(self, hostname: str, port: int) -> None:
+        pass
+
+    def close(self) -> None:
+        self._recv_loop.stop()
+
+    def send(self, msg: IrcMessage) -> None:
+        pass
+
+    def _read_lines(self) -> Iterator[Optional[Event]]:
+        while True:
+            try:
+                msg = self._q_server_msgs.get(timeout=0.1)
+            except QueueEmpty:
+                yield None
+                continue
+            if msg == 'FAKE_IRC_CONN_ABORT_EXCEPTION':
+                err = IrcConnAbortException(msg)
+                yield self._on_handled_loop_exception(err)
+                return
+            yield self._make_recv_event(IrcMessage.from_raw(msg))
+
+
+class _TestClientKnowingTui(ClientKnowingTui):
+    _cls_conn = _FakeIrcConnection
+
+
+class TestingClientTui(ClientTui):
+    'Collects keypresses via TestTerminal and test file, compares log results.'
+    _clients: list[_TestClientKnowingTui]
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._clients = []
+        assert isinstance(self._term, TestTerminal)
+        self._q_keypresses = self._term._q_keypresses
+        with _PATH_TEST_TXT.open('r', encoding='utf8') as f:
+            self._playbook = tuple(line[:-1] for line in f.readlines())
+        self._playbook_idx = -1
+        self._play_till_next_log()
+
+    def _new_client(self, conn_setup: IrcConnSetup) -> _TestClientKnowingTui:
+        self._clients += [_TestClientKnowingTui(_q_out=self._q_out,
+                                                conn_setup=conn_setup)]
+        return self._clients[-1]
+
+    def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
+        win_ids, logged_msg = super().log(msg, **kwargs)
+        time_str, msg_sans_time = logged_msg.split(' ', maxsplit=1)
+        assert len(time_str) == 8
+        for c in time_str[:2] + time_str[3:5] + time_str[6:]:
+            assert c.isdigit()
+        assert time_str[2] == ':' and time_str[5] == ':'
+        context, expected_msg = self._playbook[self._playbook_idx
+                                               ].split(maxsplit=1)
+        if ':' in context:
+            _, context = context.split(':')
+        expected_win_ids = tuple(int(idx) for idx in context.split(',') if idx)
+        info = (self._playbook_idx + 1,
+                'WANTED:', expected_win_ids, expected_msg,
+                'GOT:', win_ids, msg_sans_time)
+        assert expected_msg == msg_sans_time, info
+        assert expected_win_ids == win_ids, info
+        self._play_till_next_log()
+        return win_ids, logged_msg
+
+    def _play_till_next_log(self) -> None:
+        while True:
+            self._playbook_idx += 1
+            line = self._playbook[self._playbook_idx]
+            if line[:1] == '#' or not line.strip():
+                continue
+            context, msg = line.split(' ', maxsplit=1)
+            if context == 'repeat':
+                start, end = msg.split(':')
+                self._playbook = (self._playbook[:self._playbook_idx + 1]
+                                  + self._playbook[int(start):int(end)]
+                                  + self._playbook[self._playbook_idx + 1:])
+                continue
+            if context == '>':
+                for c in msg:
+                    self._q_keypresses.put(c)
+                self._q_keypresses.put('KEY_ENTER')
+                continue
+            if ':' in context and msg.startswith('< '):
+                client_id, win_ids = context.split(':')
+                client = self._clients[int(client_id)]
+                assert isinstance(client.conn, _FakeIrcConnection)
+                client.conn.put_server_msg(msg[2:])
+                if not win_ids:
+                    continue
+            break
diff --git a/src/ircplom/tui_base.py b/src/ircplom/tui_base.py
new file mode 100644 (file)
index 0000000..73115a1
--- /dev/null
@@ -0,0 +1,734 @@
+'Base Terminal and TUI management.'
+# built-ins
+from abc import ABC, abstractmethod
+from base64 import b64decode
+from contextlib import contextmanager
+from datetime import datetime
+from inspect import _empty as inspect_empty, signature, stack
+from signal import SIGWINCH, signal
+from typing import (Callable, Generator, Iterator, NamedTuple, Optional,
+                    Sequence)
+# requirements.txt
+from blessed import Terminal as BlessedTerminal
+# ourselves
+from ircplom.events import AffectiveEvent, Loop, QueueMixin, QuitEvent
+
+_LOG_PREFIX_DEFAULT = '#'
+_LOG_PREFIX_ALERT = '!'
+
+_MIN_HEIGHT = 4
+_MIN_WIDTH = 32
+
+_TIMEOUT_KEYPRESS_LOOP = 0.5
+_B64_PREFIX = 'b64:'
+_OSC52_PREFIX = b']52;c;'
+_PASTE_DELIMITER = '\007'
+
+_PROMPT_TEMPLATE = '> '
+_PROMPT_ELL_IN = '<…'
+_PROMPT_ELL_OUT = '…>'
+
+_CHAR_RESIZE = chr(12)
+_KEYBINDINGS = {
+    'KEY_BACKSPACE': ('window.prompt.backspace',),
+    'KEY_ENTER': ('prompt_enter',),
+    'KEY_LEFT': ('window.prompt.move_cursor', 'left'),
+    'KEY_RIGHT': ('window.prompt.move_cursor', 'right'),
+    'KEY_UP': ('window.prompt.scroll', 'up'),
+    'KEY_DOWN': ('window.prompt.scroll', 'down'),
+    'KEY_PGUP': ('window.history.scroll', 'up'),
+    'KEY_PGDOWN': ('window.history.scroll', 'down'),
+    'esc:91:49:59:51:68': ('window', 'left'),
+    'esc:91:49:59:51:67': ('window', 'right'),
+    'KEY_F1': ('window.paste',),
+}
+CMD_SHORTCUTS: dict[str, str] = {}
+
+
+class _YX(NamedTuple):
+    y: int
+    x: int
+
+
+class _Widget(ABC):
+    _tainted: bool = True
+    _sizes = _YX(-1, -1)
+
+    @property
+    def _drawable(self) -> bool:
+        return len([m for m in self._sizes if m < 1]) == 0
+
+    def taint(self) -> None:
+        'Declare as in need of re-drawing.'
+        self._tainted = True
+
+    @property
+    def tainted(self) -> bool:
+        'If in need of re-drawing.'
+        return self._tainted
+
+    def set_geometry(self, sizes: _YX) -> None:
+        'Update widget\'s sizues, re-generate content where necessary.'
+        self.taint()
+        self._sizes = sizes
+
+    def draw(self) -> None:
+        'Print widget\'s content in shape appropriate to set geometry.'
+        if self._drawable:
+            self._draw()
+            self._tainted = False
+
+    @abstractmethod
+    def _draw(self) -> None:
+        pass
+
+
+class _ScrollableWidget(_Widget):
+    _history_idx: int
+
+    def __init__(self, write: Callable[..., None], **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._write = write
+        self._history: list[str] = []
+
+    def append(self, to_append: str) -> None:
+        'Append to scrollable history.'
+        self._history += [to_append]
+
+    @abstractmethod
+    def _scroll(self, up=True) -> None:
+        self.taint()
+
+    def cmd__scroll(self, direction: str) -> None:
+        'Scroll through stored content/history.'
+        self._scroll(up=direction == 'up')
+
+
+class _HistoryWidget(_ScrollableWidget):
+    _last_read: int = 0
+    _y_pgscroll: int
+
+    def __init__(self, wrap: Callable[[str], list[str]], **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._wrap = wrap
+        self._wrapped_idx = self._history_idx = -1
+        self._wrapped: list[tuple[Optional[int], str]] = []
+
+    def _add_wrapped(self, idx_original, line) -> int:
+        wrapped_lines = self._wrap(line)
+        self._wrapped += [(idx_original, line) for line in wrapped_lines]
+        return len(wrapped_lines)
+
+    def set_geometry(self, sizes: _YX) -> None:
+        super().set_geometry(sizes)
+        if self._drawable:
+            self._y_pgscroll = self._sizes.y // 2
+            self._wrapped.clear()
+            self._wrapped += [(None, '')] * self._sizes.y
+            if self._history:
+                for idx_history, line in enumerate(self._history):
+                    self._add_wrapped(idx_history, line)
+                wrapped_lines_for_history_idx = [
+                        t for t in self._wrapped
+                        if t[0] == len(self._history) + self._history_idx]
+                idx_their_last = self._wrapped.index(
+                        wrapped_lines_for_history_idx[-1])
+                self._wrapped_idx = idx_their_last - len(self._wrapped)
+
+    def append(self, to_append: str) -> None:
+        super().append(to_append)
+        self.taint()
+        if self._history_idx < -1:
+            self._history_idx -= 1
+        if self._drawable:
+            n_wrapped_lines = self._add_wrapped(len(self._history)
+                                                - 1, to_append)
+            if self._wrapped_idx < -1:
+                self._wrapped_idx -= n_wrapped_lines
+
+    def _draw(self) -> None:
+        start_idx = self._wrapped_idx - self._sizes.y + 1
+        end_idx = self._wrapped_idx
+        to_write = [t[1] for t in self._wrapped[start_idx:end_idx]]
+        if self._wrapped_idx < -1:
+            scroll_info = f'vvv [{(-1) * self._wrapped_idx}] '
+            scroll_info += 'v' * (self._sizes.x - len(scroll_info))
+            to_write += [scroll_info]
+        else:
+            to_write += [self._wrapped[self._wrapped_idx][1]]
+        for i, line in enumerate(to_write):
+            self._write(line, i)
+        self._last_read = len(self._history)
+
+    @property
+    def n_lines_unread(self) -> int:
+        'How many new lines have been logged since last focus.'
+        return len(self._history) - self._last_read
+
+    def _scroll(self, up: bool = True) -> None:
+        super()._scroll(up)
+        if self._drawable:
+            if up:
+                self._wrapped_idx = max(
+                        self._sizes.y + 1 - len(self._wrapped),
+                        self._wrapped_idx - self._y_pgscroll)
+            else:
+                self._wrapped_idx = min(
+                        -1, self._wrapped_idx + self._y_pgscroll)
+            history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0]
+            if history_idx_to_wrapped_idx is not None:
+                self._history_idx = history_idx_to_wrapped_idx\
+                        - len(self._history)
+
+
+class PromptWidget(_ScrollableWidget):
+    'Manages/displays keyboard input field.'
+    _history_idx: int = 0
+    _input_buffer_unsafe: str
+    _cursor_x: int
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._reset_buffer('')
+
+    @property
+    def prefix(self) -> str:
+        'Main input prefix.'
+        return _PROMPT_TEMPLATE[:]
+
+    @property
+    def _input_buffer(self) -> str:
+        return self._input_buffer_unsafe[:]
+
+    @_input_buffer.setter
+    def _input_buffer(self, content) -> None:
+        self.taint()
+        self._input_buffer_unsafe = content
+
+    def _draw(self) -> None:
+        prefix = self.prefix[:]
+        content = self._input_buffer
+        if self._cursor_x == len(self._input_buffer):
+            content += ' '
+        half_width = (self._sizes.x - len(prefix)) // 2
+        offset = 0
+        if len(prefix) + len(content) > self._sizes.x\
+                and self._cursor_x > half_width:
+            prefix += _PROMPT_ELL_IN
+            offset = min(len(prefix) + len(content) - self._sizes.x,
+                         self._cursor_x - half_width + len(_PROMPT_ELL_IN))
+        cursor_x_to_write = len(prefix) + self._cursor_x - offset
+        to_write = f'{prefix}{content[offset:]}'
+        if len(to_write) > self._sizes.x:
+            to_write = (to_write[:self._sizes.x-len(_PROMPT_ELL_OUT)]
+                        + _PROMPT_ELL_OUT)
+        self._write(to_write[:cursor_x_to_write], self._sizes.y,
+                    padding=False)
+        self._write(to_write[cursor_x_to_write], attribute='reverse',
+                    padding=False)
+        self._write(to_write[cursor_x_to_write + 1:])
+
+    def _archive_prompt(self) -> None:
+        self.append(self._input_buffer)
+        self._reset_buffer('')
+
+    def _scroll(self, up: bool = True) -> None:
+        super()._scroll(up)
+        if up and -(self._history_idx) < len(self._history):
+            if self._history_idx == 0 and self._input_buffer:
+                self._archive_prompt()
+                self._history_idx -= 1
+            self._history_idx -= 1
+            self._reset_buffer(self._history[self._history_idx])
+        elif not up:
+            if self._history_idx < 0:
+                self._history_idx += 1
+                if self._history_idx == 0:
+                    self._reset_buffer('')
+                else:
+                    self._reset_buffer(self._history[self._history_idx])
+            elif self._input_buffer:
+                self._archive_prompt()
+
+    def insert(self, to_insert: str) -> None:
+        'Insert into prompt input buffer.'
+        self._cursor_x += len(to_insert)
+        self._input_buffer = (self._input_buffer[:self._cursor_x - 1]
+                              + to_insert
+                              + self._input_buffer[self._cursor_x - 1:])
+        self._history_idx = 0
+
+    def cmd__backspace(self) -> None:
+        'Truncate current content by one character, if possible.'
+        if self._cursor_x > 0:
+            self._cursor_x -= 1
+            self._input_buffer = (self._input_buffer[:self._cursor_x]
+                                  + self._input_buffer[self._cursor_x + 1:])
+            self._history_idx = 0
+
+    def cmd__move_cursor(self, direction: str) -> None:
+        'Move cursor one space into direction ("left" or "right") if possible.'
+        if direction == 'left' and self._cursor_x > 0:
+            self._cursor_x -= 1
+        elif direction == 'right'\
+                and self._cursor_x < len(self._input_buffer):
+            self._cursor_x += 1
+        else:
+            return
+        self.taint()
+
+    def _reset_buffer(self, content: str) -> None:
+        self._input_buffer = content
+        self._cursor_x = len(self._input_buffer)
+
+    def enter(self) -> str:
+        'Return current content while also clearing and then redrawing.'
+        to_return = self._input_buffer[:]
+        if to_return:
+            self._archive_prompt()
+        return to_return
+
+
+class _StatusLine(_Widget):
+
+    def __init__(self, write: Callable, windows: list['Window'], **kwargs
+                 ) -> None:
+        super().__init__(**kwargs)
+        self.idx_focus = 0
+        self._windows = windows
+        self._write = write
+
+    def _draw(self) -> None:
+        listed = []
+        focused = None
+        for w in self._windows:
+            item = str(w.idx)
+            if (n := w.history.n_lines_unread):
+                item = f'({item}:{n})'
+            if w.idx == self.idx_focus:
+                focused = w
+                item = f'[{item}]'
+            listed += [item]
+        assert isinstance(focused, Window)
+        left = f'{focused.title})'
+        right = f'({" ".join(listed)}'
+        width_gap = max(1, (self._sizes.x - len(left) - len(right)))
+        self._write(left + '=' * width_gap + right, self._sizes.y)
+
+
+class Window:
+    'Collection of widgets filling entire screen.'
+    _y_status: int
+    _drawable = False
+    prompt: PromptWidget
+    _title = ':start'
+    _last_today = ''
+
+    def __init__(self, idx: int, term: 'Terminal', **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.idx = idx
+        self._term = term
+        self.history = _HistoryWidget(wrap=self._term.wrap,
+                                      write=self._term.write)
+        self.prompt = self.__annotations__['prompt'](write=self._term.write)
+        if hasattr(self._term, 'size'):
+            self.set_geometry()
+
+    def ensure_date(self, today: str) -> None:
+        'Log date of today if it has not been logged yet.'
+        if today != self._last_today:
+            self._last_today = today
+            self.log(today)
+
+    def log(self, msg: str) -> None:
+        'Append msg to .history.'
+        self.history.append(msg)
+
+    def taint(self) -> None:
+        'Declare all widgets as in need of re-drawing.'
+        self.history.taint()
+        self.prompt.taint()
+
+    @property
+    def tainted(self) -> bool:
+        'If any widget in need of re-drawing.'
+        return self.history.tainted or self.prompt.tainted
+
+    def set_geometry(self) -> None:
+        'Set geometry for widgets.'
+        self._drawable = False
+        if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH:
+            for widget in (self.history, self.prompt):
+                widget.set_geometry(_YX(-1, -1))
+            return
+        self._y_status = self._term.size.y - 2
+        self.history.set_geometry(_YX(self._y_status, self._term.size.x))
+        self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
+        self._drawable = True
+
+    @property
+    def title(self) -> str:
+        'Window title to display in status line.'
+        return self._title
+
+    def draw_tainted(self) -> None:
+        'Draw tainted widgets (or message that screen too small).'
+        if self._drawable:
+            for widget in [w for w in (self.history, self.prompt)
+                           if w.tainted]:
+                widget.draw()
+        elif self._term.size.x > 0:
+            lines = ['']
+            for i, c in enumerate('screen too small'):
+                if i > 0 and 0 == i % self._term.size.x:
+                    lines += ['']
+                lines[-1] += c
+            for y, line in enumerate(lines):
+                self._term.write(line, y)
+
+    def cmd__paste(self) -> None:
+        'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
+        self.history.append(f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
+
+
+class TuiEvent(AffectiveEvent):
+    'To affect TUI.'
+
+
+class TerminalInterface(ABC):
+    'What BaseTui expects from a Terminal.'
+    size: _YX
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+
+    @abstractmethod
+    @contextmanager
+    def setup(self) -> Generator:
+        'Combine multiple contexts into one and run keypress loop.'
+
+    @abstractmethod
+    def calc_geometry(self) -> None:
+        '(Re-)calculate .size..'
+
+    @abstractmethod
+    def flush(self) -> None:
+        'Flush terminal.'
+
+    @abstractmethod
+    def wrap(self, line: str) -> list[str]:
+        'Wrap line to list of lines fitting into terminal width.'
+
+    @abstractmethod
+    def write(self,
+              msg: str = '',
+              start_y: Optional[int] = None,
+              attribute: Optional[str] = None,
+              padding: bool = True
+              ) -> None:
+        'Print to terminal, with position, padding to line end, attributes.'
+
+    @abstractmethod
+    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
+        pass
+
+
+class BaseTui(QueueMixin):
+    'Base for graphical user interface elements.'
+
+    def __init__(self, term: TerminalInterface, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._term = term
+        self._window_idx = 0
+        self._windows: list[Window] = []
+        self._status_line = _StatusLine(write=self._term.write,
+                                        windows=self._windows)
+        self._new_window()
+        self._set_screen()
+        signal(SIGWINCH, lambda *_: self._set_screen())
+
+    def _log_target_wins(self, **_) -> Sequence[Window]:
+        # separated to serve as hook for subclass window selection
+        return [self.window]
+
+    def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
+        'Write with timestamp, prefix to what window ._log_target_wins offers.'
+        prefix = kwargs.get('prefix', _LOG_PREFIX_DEFAULT)
+        if kwargs.get('alert', False):
+            prefix = _LOG_PREFIX_ALERT + prefix
+        now = str(datetime.now())
+        today, time = now[:10], now[11:19]
+        msg = f'{time} {prefix} {msg}'
+        affected_win_indices = []
+        for win in self._log_target_wins(**kwargs):
+            affected_win_indices += [win.idx]
+            win.ensure_date(today)
+            win.log(msg)
+            if win != self.window:
+                self._status_line.taint()
+        return tuple(affected_win_indices), msg
+
+    def _new_window(self, win_class=Window, **kwargs) -> Window:
+        new_idx = len(self._windows)
+        win = win_class(idx=new_idx, term=self._term, **kwargs)
+        self._windows += [win]
+        return win
+
+    def redraw_affected(self) -> None:
+        'On focused window call .draw, then flush screen.'
+        self.window.draw_tainted()
+        if self._status_line.tainted:
+            self._status_line.draw()
+        self._term.flush()
+
+    def _set_screen(self) -> None:
+        'Calc screen geometry into windows, then call .redraw_affected.'
+        self._term.calc_geometry()
+        for window in self._windows:
+            window.set_geometry()
+        self._status_line.set_geometry(_YX(self._term.size.y - 2,
+                                           self._term.size.x))
+        self.redraw_affected()
+
+    @property
+    def window(self) -> Window:
+        'Currently selected Window.'
+        return self._windows[self._window_idx]
+
+    def _switch_window(self, idx: int) -> None:
+        self.window.taint()
+        self._status_line.idx_focus = self._window_idx = idx
+        self._status_line.taint()
+
+    @property
+    def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]],
+                                           int, tuple[str, ...]]]:
+        cmds = {}
+        method_name_prefix = 'cmd__'
+        base = 'self'
+        for path in (base, f'{base}.window', f'{base}.window.prompt',
+                     f'{base}.window.history'):
+            for cmd_method_name in [name for name in dir(eval(path))
+                                    if name.startswith(method_name_prefix)]:
+                path_prefix = f'{path}.'
+                cmd_name = (path_prefix[len(base)+1:]
+                            + cmd_method_name[len(method_name_prefix):])
+                method = eval(f'{path_prefix}{cmd_method_name}')
+                n_args_min = 0
+                arg_names = []
+                for arg_name, param in signature(method).parameters.items():
+                    arg_names += [arg_name]
+                    n_args_min += int(param.default == inspect_empty)
+                cmds[cmd_name] = (method, n_args_min, tuple(arg_names))
+        for key, target in CMD_SHORTCUTS.items():
+            if target in cmds:
+                cmds[key] = cmds[target]
+        return cmds
+
+    def handle_keyboard_event(self, typed_in: str) -> None:
+        'Translate keyboard input into appropriate actions.'
+        if typed_in[0] == _CHAR_RESIZE:
+            self._set_screen()
+            return
+        if typed_in in _KEYBINDINGS:
+            cmd_data = _KEYBINDINGS[typed_in]
+            self._commands[cmd_data[0]][0](*cmd_data[1:])
+        elif typed_in.startswith(_B64_PREFIX):
+            encoded = typed_in[len(_B64_PREFIX):]
+            to_paste = ''
+            for i, c in enumerate(b64decode(encoded).decode('utf-8')):
+                if i > 512:
+                    break
+                if c.isprintable():
+                    to_paste += c
+                elif c.isspace():
+                    to_paste += ' '
+                else:
+                    to_paste += '#'
+            self.window.prompt.insert(to_paste)
+        elif len(typed_in) == 1:
+            self.window.prompt.insert(typed_in)
+        else:
+            self.log(f'unknown keyboard input: {typed_in}', alert=True)
+        self.redraw_affected()
+
+    def cmd__prompt_enter(self) -> None:
+        'Get prompt content from .window.prompt.enter, parse to & run command'
+        to_parse = self.window.prompt.enter()
+        if not to_parse:
+            return
+        alert: Optional[str] = None
+        if to_parse[0] == '/':
+            toks = to_parse.split(maxsplit=1)
+            cmd_name = toks.pop(0)
+            cmd, n_args_min, arg_names = self._commands.get(cmd_name[1:],
+                                                            (None, 0, ()))
+            if not cmd:
+                alert = f'{cmd_name} unknown'
+            elif cmd.__name__ == stack()[0].function:
+                alert = f'{cmd_name} would loop into ourselves'
+            else:
+                n_args_max = len(arg_names)
+                if toks and not n_args_max:
+                    alert = f'{cmd_name} given argument(s) while none expected'
+                else:
+                    if toks:
+                        while ' ' in toks[-1] and len(toks) < n_args_max:
+                            toks = toks[:-1] + toks[-1].split(maxsplit=1)
+                    if len(toks) < n_args_min:
+                        alert = f'{cmd_name} too few arguments '\
+                                + f'(given {len(toks)}, need {n_args_min})'
+                    else:
+                        alert = cmd(*toks)
+        else:
+            alert = 'not prefixed by /'
+        if alert:
+            self.log(f'invalid prompt command: {alert}', alert=True)
+
+    def cmd__help(self) -> None:
+        'Print available commands.'
+        self.log('commands available in this window:')
+        to_log = []
+        for cmd_name, cmd_data in self._commands.items():
+            to_print = [cmd_name]
+            for idx, arg in enumerate(cmd_data[2]):
+                arg = arg.upper()
+                if idx >= cmd_data[1]:
+                    arg = f'[{arg}]'
+                to_print += [arg]
+            to_log += [' '.join(to_print)]
+        for item in sorted(to_log):
+            self.log(f'  /{item}')
+
+    def cmd__list(self) -> None:
+        'List available windows.'
+        self.log('windows available via /window:')
+        for win in self._windows:
+            self.log(f'  {win.idx}) {win.title}')
+
+    def cmd__quit(self) -> None:
+        'Trigger program exit.'
+        self._put(QuitEvent())
+
+    def cmd__window(self, towards: str) -> Optional[str]:
+        'Switch window selection.'
+        n_windows = len(self._windows)
+        if n_windows < 2:
+            return 'no alternate window to move into'
+        if towards in {'left', 'right'}:
+            multiplier = (+1) if towards == 'right' else (-1)
+            window_idx = self._window_idx + multiplier
+            if not 0 <= window_idx < n_windows:
+                window_idx -= multiplier * n_windows
+        elif not towards.isdigit():
+            return f'neither "left"/"right" nor integer: {towards}'
+        else:
+            window_idx = int(towards)
+            if not 0 <= window_idx < n_windows:
+                return f'unavailable window idx: {window_idx}'
+        self._switch_window(window_idx)
+        return None
+
+
+class Terminal(QueueMixin, TerminalInterface):
+    'Abstraction of terminal interface.'
+    _cursor_yx_: _YX
+
+    def __init__(self, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self._blessed = BlessedTerminal()
+        self._cursor_yx = _YX(0, 0)
+
+    @contextmanager
+    def setup(self) -> Generator:
+        print(self._blessed.clear, end='')
+        with (self._blessed.raw(),
+              self._blessed.fullscreen(),
+              self._blessed.hidden_cursor(),
+              Loop(iterator=self._get_keypresses(), _q_out=self._q_out)):
+            yield self
+
+    @property
+    def _cursor_yx(self) -> _YX:
+        return self._cursor_yx_
+
+    @_cursor_yx.setter
+    def _cursor_yx(self, yx: _YX) -> None:
+        print(self._blessed.move_yx(yx.y, yx.x), end='')
+        self._cursor_yx_ = yx
+
+    def calc_geometry(self) -> None:
+        self.size = _YX(self._blessed.height, self._blessed.width)
+
+    def flush(self) -> None:
+        print('', end='', flush=True)
+
+    def wrap(self, line: str) -> list[str]:
+        return self._blessed.wrap(line, width=self.size.x,
+                                  subsequent_indent=' '*4)
+
+    def write(self,
+              msg: str = '',
+              start_y: Optional[int] = None,
+              attribute: Optional[str] = None,
+              padding: bool = True
+              ) -> None:
+        if start_y is not None:
+            self._cursor_yx = _YX(start_y, 0)
+        # ._blessed.length can slow down things notably: only use where needed!
+        end_x = self._cursor_yx.x + (len(msg) if msg.isascii()
+                                     else self._blessed.length(msg))
+        len_padding = self.size.x - end_x
+        if len_padding < 0:
+            msg = self._blessed.truncate(msg, self.size.x - self._cursor_yx.x)
+        elif padding:
+            msg += ' ' * len_padding
+            end_x = self.size.x
+        if attribute:
+            msg = getattr(self._blessed, attribute)(msg)
+        print(msg, end='')
+        self._cursor_yx = _YX(self._cursor_yx.y, end_x)
+
+    def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
+        '''Loop through keypresses from terminal, expand blessed's handling.
+
+        Explicitly collect KEY_ESCAPE-modified key sequences, and recognize
+        OSC52-prefixed pastables to return the respective base64 code,
+        prefixed with _B64_PREFIX.
+        '''
+        while True:
+            to_yield = ''
+            ks = self._blessed.inkey(
+                timeout=_TIMEOUT_KEYPRESS_LOOP,  # how long until yield None,
+                esc_delay=0)                     # incl. until thread dies
+            if ks.name != 'KEY_ESCAPE':
+                to_yield = f'{ks.name if ks.name else ks}'
+            else:
+                chars = b''
+                while (new_chars := self._blessed.inkey(timeout=0, esc_delay=0
+                                                        ).encode('utf-8')):
+                    chars += new_chars
+                len_prefix = len(_OSC52_PREFIX)
+                if chars[:len_prefix] == _OSC52_PREFIX:
+                    to_yield = _B64_PREFIX[:]
+                    # sometimes, prev .inkey got some or all (including paste
+                    # delimiter) of the paste code (maybe even more), so first
+                    # harvest potential remains of chars post prefix …
+                    caught_delimiter = False
+                    post_prefix_str = chars[len_prefix:].decode('utf-8')
+                    for idx, c in enumerate(post_prefix_str):
+                        if c == _PASTE_DELIMITER:
+                            caught_delimiter = True
+                            if (remains := post_prefix_str[idx + 1:]):
+                                self._blessed.ungetch(remains)
+                            break
+                        to_yield += c
+                    # … before .getch() further until expected delimiter found
+                    if not caught_delimiter:
+                        while (c := self._blessed.getch()) != _PASTE_DELIMITER:
+                            to_yield += c
+                else:
+                    to_yield = 'esc:' + ':'.join([str(int(b)) for b in chars])
+            yield (TuiEvent.affector('handle_keyboard_event'
+                                     ).kw(typed_in=to_yield) if to_yield
+                   else None)
diff --git a/src/requirements.txt b/src/requirements.txt
new file mode 100644 (file)
index 0000000..d43de1b
--- /dev/null
@@ -0,0 +1 @@
+blessed
diff --git a/src/run.py b/src/run.py
new file mode 100755 (executable)
index 0000000..42f9dc9
--- /dev/null
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+'Attempt at an IRC client.'
+from queue import SimpleQueue
+from sys import argv
+from ircplom.events import ExceptionEvent, QuitEvent
+from ircplom.client import ClientsDb, ClientEvent, NewClientEvent
+from ircplom.tui_base import BaseTui, Terminal, TerminalInterface, TuiEvent
+from ircplom.client_tui import ClientTui
+from ircplom.testing import TestTerminal, TestingClientTui
+
+
+def main_loop(cls_term: type[TerminalInterface], cls_tui: type[BaseTui]
+              ) -> None:
+    'Main execution code / loop.'
+    q_events: SimpleQueue = SimpleQueue()
+    clients_db: ClientsDb = {}
+    try:
+        with cls_term(_q_out=q_events).setup() as term:
+            tui = cls_tui(_q_out=q_events, term=term)
+            while True:
+                event = q_events.get()
+                if isinstance(event, QuitEvent):
+                    break
+                if isinstance(event, ExceptionEvent):
+                    raise event.exception
+                if isinstance(event, TuiEvent):
+                    event.affect(tui)
+                elif isinstance(event, NewClientEvent):
+                    event.affect(clients_db)
+                elif isinstance(event, ClientEvent):
+                    event.affect(clients_db[event.client_id])
+    finally:
+        for client in clients_db.values():
+            client.close()
+
+
+if __name__ == '__main__':
+    if len(argv) > 1 and argv[1] == 'test':
+        main_loop(TestTerminal, TestingClientTui)
+        print('test finished')
+    else:
+        main_loop(Terminal, ClientTui)