From 40c0f6243d16121696e33e2e1ce5068f368f6472 Mon Sep 17 00:00:00 2001 From: Sundruid Date: Thu, 30 Jul 2026 20:54:44 -0700 Subject: [PATCH] Release 1.6.1 live refresh and forum editor --- CHANGELOG.md | 16 ++ README.md | 10 +- docs/ARCHITECTURE.md | 3 + docs/OPERATIONS.md | 3 + docs/images/cataclysmbbs-overview.svg | 1 + docs/images/cataclysmbbs-topic.svg | 1 + src/neonbbs/__init__.py | 2 +- src/neonbbs/ui.py | 349 +++++++++++++++++++++++--- tests/test_service.py | 268 ++++++++++++++++++++ 9 files changed, 619 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b898bc..c9e7e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to CataclysmBBS are recorded here. +## 1.6.1 — 2026-07-30 + +### Fixed + +- Replaced the unreliable cross-thread curses wake-up with an OS pipe and + `select()`, so pushed BBS updates repaint immediately without a keypress or + periodic polling. +- Replaced the sequential forum name and description prompts with one editor + that shows both fields together and provides `[S]ave`, `[C]ontinue`, and + `[B]ack`. + +### Added + +- Added the running CataclysmBBS version at the far-right edge of every footer + color bar. + ## 1.6.0 — 2026-07-30 ### Added diff --git a/README.md b/README.md index 5a9c484..1e09cb9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ CataclysmBBS is an old-school, SSH-first bulletin board for Raspberry Pi and oth Linux systems. Every member authenticates as a real operating-system user, runs `bb`, and gets a responsive neon-green terminal interface. -> Status: version 1.6.0 is deployed and tested on Raspberry Pi OS based on +> Status: version 1.6.1 is deployed and tested on Raspberry Pi OS based on > Debian 13 (ARM64), with Python 3.13. ## What it looks like @@ -66,7 +66,8 @@ Inside the board: and always lands on real content. - The persistent footer uses classic first-letter navigation: `[O]verview [I]RC [M]ail [U]sers [X]fers [S]earch [T]heme [H]elp [Q]uit`. - Only administrators see `[A]dmin`. + Only administrators see `[A]dmin`; the running version stays visible at the + far-right edge of the footer bar. - `n` creates a topic, `r` replies, and `f` attaches a file. - `x` opens the Transfer Center; `Space` selects multiple files and `f` selects attachment names using a wildcard. @@ -136,6 +137,9 @@ top-level forum categories, including their Overview descriptions; add, rename, move, or delete topics; add, edit, move, or delete posts; change the board's large banner name with `[I]dentity`; or manage users. `[B]ack` consistently returns to the previous screen. +Forum `[E]dit` opens one dialog with Name and Overview Description visible +together. Use Tab or the arrow keys to switch fields, then Esc for +`[S]ave`, `[C]ontinue`, or `[B]ack`. Opening posts cannot be moved or deleted separately—move or delete their topic instead. Category and content deletion requires typed confirmation, and the final forum cannot be deleted. @@ -145,6 +149,8 @@ topic, post, mail, file, user, banner, and IRC-presence changes identify only the affected resources; each client reloads its visible affected screen and coalesces bursts into one redraw. Idle clients issue no periodic screen or counter requests, which keeps slow SSH links responsive. +An OS pipe wakes the blocked curses input loop as soon as an event arrives, so +the updated screen is painted immediately without waiting for a keypress. New-user creation includes a multiline SSH-public-key paste box. BBS deletion blocks future board access, deletes private messages involving the account, and diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c95d25..ff3f5a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,6 +118,9 @@ client reloads only an affected resource when that resource is visible and preserves the selected record by stable ID. Overview, forums, topics, search, directory, mail, transfers, IRC, Admin Forums, Admin Users, and live Board Status use the same mechanism. Idle clients make no periodic state requests. +The event reader writes one byte to a nonblocking OS pipe. The main curses loop +uses `select()` on terminal input and that pipe, so a server event wakes and +repaints the TUI immediately without cross-thread curses calls or timer polling. ## Logging diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f1dc988..dca17d8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -146,6 +146,9 @@ The Overview and live Admin Board Status show the current IRC participant count. Forum, topic, post, mail, file, user, banner, and IRC changes publish small resource-scoped notifications. Connected clients automatically reload only an affected visible screen and coalesce bursts for low-bandwidth links. +The event stream wakes the TUI through an OS pipe, so changes appear without a +keypress. The footer's far-right version label identifies the running client +when reporting a display or refresh problem. ## Health and logs diff --git a/docs/images/cataclysmbbs-overview.svg b/docs/images/cataclysmbbs-overview.svg index 9e2c521..2e0a5b5 100644 --- a/docs/images/cataclysmbbs-overview.svg +++ b/docs/images/cataclysmbbs-overview.svg @@ -99,4 +99,5 @@ [↑↓] Move through channels + signals [Enter] Open [N]ew topic [O]verview [I]RC [M]ail [U]sers [X]fers [S]earch [T]heme [H]elp [A]dmin [Q]uit + v1.6.1 diff --git a/docs/images/cataclysmbbs-topic.svg b/docs/images/cataclysmbbs-topic.svg index f904ee6..2e5b0c3 100644 --- a/docs/images/cataclysmbbs-topic.svg +++ b/docs/images/cataclysmbbs-topic.svg @@ -69,4 +69,5 @@ [↑↓] Select post [R]eply [F]ile [V]iew image [D]ownload [B]ack [O]verview [I]RC [M]ail [U]sers [X]fers [S]earch [T]heme [H]elp [A]dmin [Q]uit + v1.6.1 diff --git a/src/neonbbs/__init__.py b/src/neonbbs/__init__.py index d08bdc9..d1542b5 100644 --- a/src/neonbbs/__init__.py +++ b/src/neonbbs/__init__.py @@ -1,3 +1,3 @@ """CataclysmBBS package.""" -__version__ = "1.6.0" +__version__ = "1.6.1" diff --git a/src/neonbbs/ui.py b/src/neonbbs/ui.py index a6fa12d..e8d614f 100644 --- a/src/neonbbs/ui.py +++ b/src/neonbbs/ui.py @@ -13,6 +13,7 @@ import re import select import shutil +import signal import subprocess import sys import tarfile @@ -29,9 +30,6 @@ from .client import BBSClient, ClientError -EVENT_WAKE_KEY = "\x00" - - THEMES: dict[str, dict[str, Any]] = { "neon": { "name": "Neon Terminal", @@ -719,15 +717,18 @@ def put(y: int, x: int, text: str) -> None: ) y += 1 admin_item = " [A]dmin" if profile.get("role") == "admin" else "" + footer_text = ( + " [O]verview [I]RC [M]ail [U]sers [X]fers [S]earch " + f"[T]heme [H]elp{admin_item} [Q]uit " + ) + version_label = f"v{__version__}" + version_x = width - 2 - len(version_label) put( height - 2, 2, - clipped( - " [O]verview [I]RC [M]ail [U]sers [X]fers [S]earch " - f"[T]heme [H]elp{admin_item} [Q]uit ", - width - 4, - ), + clipped(footer_text, max(0, version_x - 3)), ) + put(height - 2, version_x, version_label) return "\n".join("".join(row).rstrip() for row in canvas) @@ -761,6 +762,9 @@ def __init__(self, screen: curses.window, client: BBSClient): queue.SimpleQueue() ) self.event_thread: threading.Thread | None = None + self.event_read_fd, self.event_write_fd = os.pipe() + os.set_blocking(self.event_read_fd, False) + os.set_blocking(self.event_write_fd, False) self.uploads_dir = Path.home() / "bbs-files" / "uploads" self.downloads_dir = Path.home() / "bbs-files" / "downloads" self.uploads_dir.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -780,24 +784,54 @@ def start(self) -> None: pass self.refresh_home() self.start_event_stream() + input_fd = sys.stdin.fileno() + previous_wakeup_fd: int | None = None + try: + previous_wakeup_fd = signal.set_wakeup_fd( + self.event_write_fd + ) + except (OSError, ValueError): + previous_wakeup_fd = None + self.screen.nodelay(True) try: while self.running: self.process_server_events() self.apply_theme() self.draw() - self.screen.timeout(-1) try: key = self.screen.get_wch() except curses.error: + key = None + if key is not None: + self.handle_key(key) continue - self.handle_key(key) + try: + readable, _, _ = select.select( + [input_fd, self.event_read_fd], + [], + [], + ) + except (InterruptedError, OSError): + continue + if self.event_read_fd in readable: + self.drain_event_wake() + continue + # stdin is readable; loop once so curses can decode the key + # without blocking the event descriptor. finally: + if previous_wakeup_fd is not None: + try: + signal.set_wakeup_fd(previous_wakeup_fd) + except (OSError, ValueError): + pass if self.irc_data: try: self.client.call("irc_leave") except ClientError: pass self.stop_event_stream() + os.close(self.event_read_fd) + os.close(self.event_write_fd) def apply_theme(self) -> None: theme = THEMES.get(self.theme, THEMES["neon"]) @@ -885,7 +919,23 @@ def status(self, keys: str) -> None: self.add(height - 3, 2, " " * (width - 4), context_attr) self.add(height - 3, 2, clipped(f" {context} ", width - 4), context_attr) self.add(height - 2, 2, " " * (width - 4), self.selected_attr) - self.add(height - 2, 2, clipped(f" {standard} ", width - 4), self.selected_attr) + version_label = f"v{__version__}" + version_x = max(2, width - 2 - len(version_label)) + menu_width = max(0, version_x - 3) + self.add( + height - 2, + 2, + clipped(f" {standard} ", menu_width), + self.selected_attr, + menu_width, + ) + self.add( + height - 2, + version_x, + version_label, + self.selected_attr, + len(version_label), + ) def set_message(self, message: str, error: bool = False) -> None: self.message = message @@ -933,10 +983,20 @@ def stop_event_stream(self) -> None: def queue_server_event(self, event: dict[str, Any]) -> None: self.server_events.put(event) try: - curses.ungetch(ord(EVENT_WAKE_KEY)) - except curses.error: + os.write(self.event_write_fd, b"U") + except (BlockingIOError, OSError): pass + def drain_event_wake(self) -> None: + while True: + try: + if not os.read(self.event_read_fd, 4096): + return + except BlockingIOError: + return + except OSError: + return + def process_server_events(self) -> None: resources: set[str] = set() stream_error = "" @@ -2218,9 +2278,6 @@ def draw_help(self) -> None: self.status("[B]ack") def handle_key(self, key: Any) -> None: - if key == EVENT_WAKE_KEY or key == 0: - self.process_server_events() - return self.clear_message() height, width = self.screen.getmaxyx() if key == curses.KEY_RESIZE: @@ -2503,6 +2560,248 @@ def prompt_line( elif isinstance(key, str) and key.isprintable() and len(value) < max_length: value += key + @modal_window + def forum_edit_dialog( + self, + forum: dict[str, Any], + ) -> tuple[str, str] | None: + height, width = self.screen.getmaxyx() + box_width = min(width - 4, 100) + box_height = min(height - 4, 16) + y, x = (height - box_height) // 2, (width - box_width) // 2 + win = curses.newwin(box_height, box_width, y, x) + win.keypad(True) + values = [ + str(forum.get("name", "")), + str(forum.get("description", "")), + ] + limits = [60, 200] + cursors = [len(values[0]), len(values[1])] + active = 0 + form_error = "" + content_width = max(8, box_width - 6) + description_rows = max(1, box_height - 10) + + def field_window( + value: str, cursor: int, field_width: int + ) -> tuple[str, int]: + start = max( + 0, + min( + cursor - field_width + 1, + max(0, len(value) - field_width), + ), + ) + return value[start : start + field_width], cursor - start + + curses.curs_set(1) + try: + while True: + win.erase() + win.attron(self.primary) + win.box() + win.attroff(self.primary) + win.addnstr( + 0, + 3, + f" EDIT FORUM #{forum.get('id', '?')} ", + box_width - 6, + self.bright, + ) + + name_marker = "▶" if active == 0 else " " + description_marker = "▶" if active == 1 else " " + win.addnstr( + 2, + 2, + f"{name_marker} NAME", + box_width - 4, + self.accent if active == 0 else self.primary, + ) + shown_name, name_cursor = field_window( + values[0], cursors[0], content_width + ) + name_attr = ( + self.selected_attr if active == 0 else self.primary + ) + win.addnstr( + 3, + 3, + shown_name.ljust(content_width), + content_width, + name_attr, + ) + + win.addnstr( + 5, + 2, + f"{description_marker} OVERVIEW DESCRIPTION", + box_width - 4, + self.accent if active == 1 else self.primary, + ) + description = values[1] + lines = [ + description[index : index + content_width] + for index in range(0, len(description), content_width) + ] or [""] + if len(description) % content_width == 0: + lines.append("") + description_line = cursors[1] // content_width + description_column = cursors[1] % content_width + visible_start = max( + 0, description_line - description_rows + 1 + ) + visible_lines = lines[ + visible_start : visible_start + description_rows + ] + description_attr = ( + self.selected_attr if active == 1 else self.primary + ) + for row in range(description_rows): + line = ( + visible_lines[row] + if row < len(visible_lines) + else "" + ) + win.addnstr( + 6 + row, + 3, + line.ljust(content_width), + content_width, + description_attr, + ) + + if form_error: + win.addnstr( + box_height - 3, + 2, + form_error, + box_width - 4, + curses.color_pair(5) | curses.A_BOLD, + ) + footer = ( + "[Tab/↑↓] Field [←→] Cursor " + "[Esc] [S]ave / [C]ontinue / [B]ack" + if box_width >= 76 + else "[Tab] Field [Esc] [S]ave/[C]ontinue/[B]ack" + ) + win.addnstr( + box_height - 2, + 2, + footer, + box_width - 4, + self.accent, + ) + + if active == 0: + cursor_y = 3 + cursor_x = 3 + min( + name_cursor, content_width - 1 + ) + else: + cursor_y = ( + 6 + description_line - visible_start + ) + cursor_x = 3 + min( + description_column, content_width - 1 + ) + try: + win.move(cursor_y, cursor_x) + except curses.error: + pass + win.refresh() + key = win.get_wch() + form_error = "" + + if key == "\x1b": + curses.curs_set(0) + action = self.choose_from( + "EDIT FORUM", + [ + "[S]ave changes", + "[C]ontinue editing", + "[B]ack without saving", + ], + ) + if action == 0: + name = values[0].strip() + description = values[1].strip() + if name and description: + return name, description + form_error = ( + "NAME AND OVERVIEW DESCRIPTION ARE REQUIRED" + ) + elif action == 2 or action is None: + return None + curses.curs_set(1) + continue + + if key in ( + "\t", + curses.KEY_UP, + curses.KEY_DOWN, + "\n", + "\r", + curses.KEY_ENTER, + ): + active = 1 - active + continue + if key == curses.KEY_LEFT: + cursors[active] = max( + 0, cursors[active] - 1 + ) + continue + if key == curses.KEY_RIGHT: + cursors[active] = min( + len(values[active]), + cursors[active] + 1, + ) + continue + if key == curses.KEY_HOME: + cursors[active] = 0 + continue + if key == curses.KEY_END: + cursors[active] = len(values[active]) + continue + if key in ( + curses.KEY_BACKSPACE, + "\b", + "\x7f", + ): + cursor = cursors[active] + if cursor: + values[active] = ( + values[active][: cursor - 1] + + values[active][cursor:] + ) + cursors[active] -= 1 + continue + if key == curses.KEY_DC: + cursor = cursors[active] + values[active] = ( + values[active][:cursor] + + values[active][cursor + 1 :] + ) + continue + if key == "\x15": + values[active] = "" + cursors[active] = 0 + continue + if ( + isinstance(key, str) + and key.isprintable() + and len(values[active]) < limits[active] + ): + cursor = cursors[active] + values[active] = ( + values[active][:cursor] + + key + + values[active][cursor:] + ) + cursors[active] += 1 + finally: + curses.curs_set(0) + @modal_window def paste_box( self, @@ -3993,22 +4292,10 @@ def admin_edit_forum(self) -> None: if forum is None: self.set_message("NO FORUM SELECTED", True) return - name = self.prompt_line( - "EDIT FORUM", - f"NAME FOR #{forum['id']}:", - 60, - initial=forum["name"], - ) - if not name: - return - description = self.prompt_line( - "EDIT FORUM", - "OVERVIEW DESCRIPTION:", - 200, - initial=forum["description"], - ) - if not description: + edited = self.forum_edit_dialog(forum) + if edited is None: return + name, description = edited try: result = self.client.call( "admin_edit_forum", diff --git a/tests/test_service.py b/tests/test_service.py index abd5169..aa9bcba 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,13 +1,18 @@ from __future__ import annotations import io +import os import pwd +import queue +import select import socket import tarfile import tempfile import threading +import time import unittest from pathlib import Path +from unittest import mock from neonbbs.client import BBSClient, ClientError from neonbbs.core import BBSCore @@ -299,6 +304,9 @@ def test_home_has_two_nonselectable_rows_before_latest_signals( self.assertTrue(blank_line.endswith("│")) self.assertEqual(blank_line.strip(" │"), "") self.assertIn("2 IRC", snapshot) + self.assertTrue( + snapshot.splitlines()[-2].endswith("v1.6.1 │") + ) class Screen: @staticmethod @@ -341,6 +349,266 @@ def test_admin_status_is_a_live_page_with_back_navigation(self) -> None: App.handle_admin_status_key(app, "b") self.assertEqual(app.view, "admin") + def test_forum_edit_submits_name_and_description_together( + self, + ) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + class Client: + @staticmethod + def call(operation: str, **args: object) -> dict[str, object]: + calls.append((operation, args)) + return { + "name": args["name"], + "description": args["description"], + } + + app = App.__new__(App) + app.client = Client() + app.data = { + "forums": [ + { + "id": 7, + "name": "Old Name", + "description": "Old description", + } + ] + } + app.selected = 0 + app.forum_edit_dialog = lambda _forum: ( + "New Name", + "New Overview description", + ) + opened: list[int] = [] + app.open_admin_forums = lambda forum_id: opened.append(forum_id) + app.refresh_home = lambda: None + messages: list[str] = [] + app.set_message = lambda message, error=False: messages.append(message) + + App.admin_edit_forum(app) + + self.assertEqual( + calls, + [ + ( + "admin_edit_forum", + { + "forum_id": 7, + "name": "New Name", + "description": "New Overview description", + }, + ) + ], + ) + self.assertEqual(opened, [7]) + self.assertIn("UPDATED FORUM #7", messages[0]) + + def test_forum_edit_dialog_shows_and_edits_both_fields( + self, + ) -> None: + curses = __import__("curses") + + class Screen: + @staticmethod + def getmaxyx() -> tuple[int, int]: + return 30, 110 + + class Window: + def __init__(self) -> None: + self.rows = [[" "] * 100 for _ in range(16)] + self.keys = iter( + ["\t", "\x15", *"Edited overview", "\x1b"] + ) + + def keypad(self, _enabled: bool) -> None: + pass + + def erase(self) -> None: + self.rows = [[" "] * 100 for _ in range(16)] + + def attron(self, _attr: int) -> None: + pass + + def attroff(self, _attr: int) -> None: + pass + + def box(self) -> None: + pass + + def addnstr( + self, + y: int, + x: int, + value: str, + length: int, + _attr: int, + ) -> None: + for index, character in enumerate(value[:length]): + if 0 <= y < 16 and 0 <= x + index < 100: + self.rows[y][x + index] = character + + def move(self, _y: int, _x: int) -> None: + pass + + def refresh(self) -> None: + pass + + def get_wch(self) -> str: + return next(self.keys) + + window = Window() + app = App.__new__(App) + app.screen = Screen() + app._modal_depth = 0 + app.force_full_redraw = False + app.choose_from = lambda _title, _choices: 0 + with ( + mock.patch("neonbbs.ui.curses.newwin", return_value=window), + mock.patch("neonbbs.ui.curses.curs_set"), + mock.patch( + "neonbbs.ui.curses.color_pair", return_value=0 + ), + ): + result = App.forum_edit_dialog( + app, + { + "id": 7, + "name": "Wrench & Wire", + "description": "Old overview", + }, + ) + + rendered = "\n".join("".join(row) for row in window.rows) + self.assertEqual( + result, ("Wrench & Wire", "Edited overview") + ) + self.assertIn("NAME", rendered) + self.assertIn("OVERVIEW DESCRIPTION", rendered) + self.assertIn("Edited overview", rendered) + self.assertIn("[S]ave", rendered) + self.assertIn("[B]ack", rendered) + + def test_server_event_pipe_wakes_blocked_ui_without_polling( + self, + ) -> None: + app = App.__new__(App) + app.server_events = queue.SimpleQueue() + app.event_read_fd, app.event_write_fd = os.pipe() + os.set_blocking(app.event_read_fd, False) + os.set_blocking(app.event_write_fd, False) + try: + App.queue_server_event( + app, + { + "event": "update", + "version": 9, + "resources": ["home"], + }, + ) + readable, _, _ = select.select( + [app.event_read_fd], [], [], 0.5 + ) + self.assertEqual(readable, [app.event_read_fd]) + App.drain_event_wake(app) + readable, _, _ = select.select( + [app.event_read_fd], [], [], 0 + ) + self.assertEqual(readable, []) + self.assertEqual( + app.server_events.get_nowait()["version"], 9 + ) + finally: + os.close(app.event_read_fd) + os.close(app.event_write_fd) + + def test_blocked_main_loop_repaints_on_server_event_without_keypress( + self, + ) -> None: + curses = __import__("curses") + + class Screen: + def __init__(self) -> None: + self.next_key: str | None = None + + def keypad(self, _enabled: bool) -> None: + pass + + def nodelay(self, _enabled: bool) -> None: + pass + + def get_wch(self) -> str: + if self.next_key is None: + raise curses.error + key, self.next_key = self.next_key, None + return key + + screen = Screen() + app = App.__new__(App) + app.screen = screen + app.running = True + app.server_events = queue.SimpleQueue() + app.event_read_fd, app.event_write_fd = os.pipe() + os.set_blocking(app.event_read_fd, False) + os.set_blocking(app.event_write_fd, False) + app.irc_data = {} + app.apply_theme = lambda: None + app.refresh_home = lambda: None + app.refresh_visible_resources = lambda resources: setattr( + app, "refreshed_resources", resources + ) + app.refreshed_resources = set() + app.draw_count = 0 + + def draw() -> None: + app.draw_count += 1 + if app.refreshed_resources: + screen.next_key = "q" + + app.draw = draw + app.handle_key = lambda key: setattr(app, "running", key != "q") + + def start_event_stream() -> None: + def publish() -> None: + time.sleep(0.05) + App.queue_server_event( + app, + { + "event": "update", + "version": 10, + "resources": ["home"], + }, + ) + + app.event_thread = threading.Thread(target=publish) + app.event_thread.start() + + app.start_event_stream = start_event_stream + app.stop_event_stream = lambda: app.event_thread.join(timeout=1) + input_read_fd, input_write_fd = os.pipe() + + class Input: + @staticmethod + def fileno() -> int: + return input_read_fd + + started = time.monotonic() + try: + with ( + mock.patch("neonbbs.ui.sys.stdin", Input()), + mock.patch("neonbbs.ui.curses.curs_set"), + mock.patch("neonbbs.ui.curses.noecho"), + mock.patch("neonbbs.ui.curses.cbreak"), + mock.patch("neonbbs.ui.curses.use_default_colors"), + ): + App.start(app) + finally: + os.close(input_read_fd) + os.close(input_write_fd) + + self.assertLess(time.monotonic() - started, 0.5) + self.assertGreaterEqual(app.draw_count, 2) + self.assertEqual(app.refreshed_resources, {"home"}) + def test_home_selection_continues_from_channels_into_recent_signals( self, ) -> None: