This document describes the internals of RetroTUI for anyone who wants to contribute, fix bugs, or build plugins.
- Windows 3.1 Experience: A faithful TUI recreation of the classic desktop environment — no X11, no Wayland, just Python and
curses. - Zero Dependencies on Linux: Only the Python 3.10+ standard library. Windows requires
pywinptyandwindows-curses. - Single-Threaded: Everything runs on one thread in a synchronous event loop. The only exception is background file operations (copy/move of large files), which run in a separate thread and are polled from the main loop.
- Testability: Core logic is decoupled from curses via a fake curses module injected in tests. No real terminal needed to run the test suite.
retrotui/
├── __main__.py # Entry point (python -m retrotui)
├── constants.py # Box-drawing chars, color pair IDs, layout constants
├── utils.py # safe_addstr, draw_box, theme_attr, key normalization
├── theme.py # Theme dataclass + built-in themes (win31, dos, win95, hacker, amiga)
│
├── core/ # Core system — the "engine"
│ ├── app.py # RetroTUI facade class (~150 methods, delegates everything)
│ ├── event_loop.py # Main loop: input → dispatch → draw
│ ├── bootstrap.py # Terminal setup (cbreak, mouse, flow control)
│ ├── rendering.py # Desktop background, icons, taskbar, status bar
│ ├── window_manager.py # Window list, z-order, focus, spawn/close
│ ├── mouse_router.py # Mouse event routing (hit-test → dispatch)
│ ├── mouse_utils.py # Mouse utility functions (arity cache, hit-tests, captures)
│ ├── key_router.py # Keyboard routing (hotkeys → active window)
│ ├── actions.py # ActionType, AppAction, ActionResult — the message contract
│ ├── action_runner.py # App-level action dispatch (_APP_REGISTRY → spawn window)
│ ├── dialog_dispatch.py # Routes ActionResult from windows to dialogs/operations
│ ├── context_menu_handler.py # Right-click menu logic
│ ├── drag_drop.py # File drag-and-drop between windows
│ ├── event_bus.py # Pub/sub event system (clipboard, file ops, window events)
│ ├── ipc.py # Window-to-window messaging (IPCRouter)
│ ├── notifications.py # Toast notification system
│ ├── signal_handler.py # Unix signal → synthetic key queue (no curses corruption)
│ ├── plugin_manager.py # Plugin lifecycle (discover, register, spawn)
│ ├── menu_builder.py # Global menu construction + plugin menu items
│ ├── icon_manager.py # Desktop icon positions (draggable, persisted)
│ ├── icon_styles.py # Icon style system (default, mini, braille, codex)
│ ├── config.py # AppConfig dataclass, TOML load/save
│ ├── clipboard.py # Internal clipboard + system clipboard bridge
│ ├── file_operations.py # File dialogs + background copy/move with progress
│ ├── terminal_session.py # PTY session (POSIX pty + Windows ConPTY via pywinpty)
│ ├── ansi.py # ANSI escape code state machine for terminal emulation
│ ├── viewer.py # File type detection → viewer window dispatch
│ ├── content.py # Static text (welcome, about, help)
│ ├── win_termios.py # Windows termios shim (ctypes → kernel32)
│ └── platform/
│ └── mouse_backend.py # Mouse event normalization (GPM, SGR, fallback)
│
├── ui/ # Reusable UI widgets
│ ├── window.py # Base Window class — the "app protocol"
│ ├── dialog.py # Dialog, InputDialog, ProgressDialog
│ ├── menu.py # MenuBar, Menu (global), WindowMenu (per-window)
│ ├── context_menu.py # ContextMenu widget
│ └── selectable_text.py # Text selection mixin (notepad, terminal, log viewer)
│
├── apps/ # Built-in applications
│ ├── app_manager.py # Desktop Icon Editor + Menu Editor
│ ├── filemanager/ # File Manager (dual-pane)
│ │ ├── window.py # FileManagerWindow (main class)
│ │ ├── core.py # PaneState, directory listing, sorting
│ │ ├── operations.py # Copy, move, rename, delete helpers
│ │ ├── bookmarks.py # Path bookmarks
│ │ └── preview.py # File preview panel
│ ├── notepad.py # Text editor (dispatch table pattern)
│ ├── terminal.py # Embedded terminal emulator
│ ├── calculator.py # Calculator
│ ├── hexviewer.py # Hex viewer
│ ├── process_manager.py # Process manager
│ ├── sysmon.py # System monitor
│ ├── logviewer.py # Log file viewer
│ ├── trash.py # Trash / recycle bin
│ ├── clipboard_viewer.py # Clipboard viewer
│ ├── control_panel.py # Control Panel / Settings
│ ├── settings.py # Settings window
│ ├── image_viewer.py # Image viewer (braille rendering)
│ ├── markdown_viewer.py # Markdown viewer
│ └── retronet.py # RetroNet (HTTP browser)
│
├── plugins/ # Plugin infrastructure
│ ├── base.py # RetroApp — ergonomic base class for plugins
│ └── loader.py # Plugin discovery (plugin.toml) + module loading
│
└── bundled_plugins/ # Shipped as plugins, loaded via the plugin system
├── charmap/ # Character Map
├── clock/ # Clock
├── image-viewer/ # Image Viewer
├── minesweeper/ # Minesweeper
├── retronet/ # RetroNet
├── snake/ # Snake
├── solitaire/ # Solitaire
├── tetris/ # Tetris
└── wifi-manager/ # WiFi Manager
core/event_loop.py → run_app_loop(app) is the heart of RetroTUI. Single-threaded, runs at ~30 FPS when active.
┌─────────────────────────────────────────────────────┐
│ Main Loop │
│ │
│ 1. Poll background operations (file copy/move) │
│ 2. Tick notifications (expire old toasts) │
│ 3. If dirty → draw_frame(app) │
│ 4. Adjust input timeout based on state: │
│ - Idle: 500ms │
│ - Live PTY session: faster polling │
│ - Background file op: faster polling │
│ 5. Read input (get_wch, blocks until timeout) │
│ 6. If no input → check signal queue, idle refresh │
│ 7. dispatch_input(app, key) → sets dirty if changed│
│ └── loop │
└─────────────────────────────────────────────────────┘
- Desktop background (theme pattern)
- Desktop icons
- Windows (bottom to top, list order = z-order)
- Global menu bar + dropdown
- Taskbar (minimized window buttons)
- Status bar
- Modal dialog (if any)
- Context menu
- Toast notifications (top-right overlay)
- Context menu (modal — swallows all input when open)
- Mouse events →
mouse_router.handle_mouse_event() - Resize events → clamp windows to new terminal size
- Keyboard →
key_router.handle_key_event()
RetroTUI is a thin facade with ~150 methods. It holds state and delegates to specialized modules:
| Concern | Module |
|---|---|
| Window list, z-order, focus | WindowManager |
| File dialogs, background ops | FileOperationManager |
| Desktop icon positions | IconPositionManager |
| File drag-and-drop | DragDropManager |
| Dialog result routing | DialogDispatcher |
| Pub/sub events | EventBus |
| Window-to-window messaging | IPCRouter |
| Toast notifications | NotificationManager |
| Mouse routing | mouse_router (free functions) |
| Keyboard routing | key_router (free functions) |
| Rendering | rendering (free functions) |
| Action dispatch | action_runner (free functions) |
| Menu construction | menu_builder (free functions) |
| Icon styling | icon_styles (free functions) |
| Signal handling | signal_handler (free functions) |
| Plugin lifecycle | plugin_manager (free functions) |
| Terminal setup | bootstrap (free functions) |
Most facade methods are one-liners. This means you can mock individual module functions in tests without patching the entire class.
Every app is a subclass of ui/window.py → Window. The protocol:
class MyApp(Window):
def draw(self, stdscr):
"""Render window content. Called every frame."""
def handle_key(self, key) -> ActionResult | None:
"""Process a keystroke. Return ActionResult to request app-level action."""
def handle_click(self, mx, my) -> ActionResult | None:
"""Process mouse click (coordinates relative to window)."""
def handle_right_click(self, mx, my) -> list | None:
"""Return context menu items, or None."""
def close(self):
"""Cleanup hook called when the window is closed."""
def on_ipc_message(self, message):
"""Receive messages from other windows (optional)."""
def subscribe_to_bus(self, bus):
"""Subscribe to event bus topics (optional)."""Windows never call app methods directly. Instead they return ActionResult objects:
# In your window's handle_key:
return ActionResult(type=ActionType.OPEN_FILE, payload="/path/to/file")
return ActionResult(type=ActionType.EXECUTE, payload=AppAction.NOTEPAD)
return ActionResult(type=ActionType.REQUEST_SAVE_AS, payload={"content": text})
return ActionResult(type=ActionType.ERROR, payload="Something went wrong")The DialogDispatcher (core/dialog_dispatch.py) routes these to the appropriate app-level handler — showing dialogs, spawning windows, starting file operations, etc.
ActionType— window-to-app requests:OPEN_FILE,EXECUTE,REQUEST_SAVE_AS,REQUEST_RENAME_ENTRY,REQUEST_DELETE_CONFIRM,REQUEST_COPY_ENTRY,REQUEST_MOVE_ENTRY,ERROR,UPDATE_CONFIG, etc.AppAction— app-level actions used by menus and icons:FILE_MANAGER,NOTEPAD,TERMINAL,FM_COPY,NP_SAVE, etc. These are the "commands" that the menu system and desktop icons trigger.
core/window_manager.py → WindowManager manages the window list.
- Z-Order: Windows are drawn in list order. Index 0 = bottom, last = top (active).
- Focus:
set_active_window(win)deactivates all others, moveswinto the top of its layer. Windows markedalways_on_topstay above normal windows. - Spawning:
_spawn_window(win)appends to the list, publisheswindow.openedon the event bus, and callswin.subscribe_to_bus()if available. - Closing:
close_window(win)callswin.close(), removes from list, activates the next visible window, publisheswindow.closed.
core/mouse_router.py routes normalized mouse events through a priority chain:
- Active drag/resize (fast-path, O(1)) — if a window is being dragged or resized, route directly.
- File drag-and-drop — handled by
DragDropManager. - Global menu bar — clicks on the top menu row.
- Right-click →
context_menu_handler.handle_right_click(). - Window hit-test — reversed loop (topmost first). Tests: close/minimize/maximize buttons → title bar → resize borders → client area.
- Desktop — icon clicks, desktop background.
Mouse events are normalized by platform/mouse_backend.py into a dict with boolean fields (is_click_like, right_click, is_drag, button1_double, etc.) to abstract away differences between GPM, SGR, and xterm mouse protocols.
core/key_router.py processes keys in this order:
normalize_app_key(key)— converts rawget_wch()values to canonical control codes.- Ctrl+Q — closes open menus first (context → window → global), then exits if none open.
- F10 / Escape — toggles menus.
- If global menu is active → consumes all keys for menu navigation.
- Tab → cycle window focus.
- Otherwise → delegates to
active_window.handle_key(key).
core/signal_handler.py installs handlers for SIGINT, SIGTERM, SIGTSTP, etc. Signals are not acted on immediately — they enqueue synthetic key codes that the event loop consumes on the next iteration. This prevents signal delivery from corrupting curses state mid-render.
apps/terminal.py provides an embedded terminal using core/terminal_session.py as the PTY backend.
Dual-platform, lazily resolved and cached:
| Platform | Backend | Shell default | Key module |
|---|---|---|---|
| Linux/macOS | pty.fork() + fcntl non-blocking |
$SHELL or /bin/sh |
pty, fcntl, termios |
| Windows | pywinpty ConPTY |
%COMSPEC% or cmd.exe |
winpty |
POSIX is always preferred when available. The Windows path only activates when winpty is importable.
core/ansi.py → AnsiStateMachine parses PTY output character by character. It yields typed tuples:
('TEXT', char, curses_attr)— printable character with SGR attributes resolved to curses color pairs.('CSI', final_char, params)— cursor movement, erase, etc.('CONTROL', char)—\n,\r,\b,\t.
core/event_bus.py → EventBus provides synchronous pub/sub. All dispatch happens on the main thread — subscribers must not block.
Built-in topics: clipboard.changed, file_op.started/completed/failed, window.opened/closed/focused, config.changed, theme.changed, ipc.message, notification.
# Subscribe
unsub = bus.subscribe("clipboard.changed", my_callback)
# Publish
bus.publish("clipboard.changed", {"text": "hello"}, source="notepad")A plugin is a directory containing plugin.toml and __init__.py:
my-plugin/
├── plugin.toml # Manifest (id, name, window size)
└── __init__.py # Must export a class named Plugin
plugin.toml example:
[plugin]
id = "my-plugin"
name = "My Plugin"
[plugin.window]
default_width = 40
default_height = 20__init__.py example:
from retrotui.plugins.base import RetroApp
class Plugin(RetroApp):
def draw_content(self, stdscr, x, y, w, h):
# Draw your app here
pass
def handle_key(self, key):
# Handle keyboard input
passplugins/loader.py searches these locations (in order):
RETROTUI_PLUGIN_DIRenv varRETROTUI_PLUGIN_PATHenv var (colon/semicolon-separated)~/.config/retrotui/plugins/retrotui/bundled_plugins/(shipped with the package)examples/plugins/(for development)
All errors during plugin loading are isolated — a broken plugin never crashes startup.
Games and utilities that ship with RetroTUI but load through the plugin system: Character Map, Clock, Image Viewer, Minesweeper, RetroNet, Snake, Solitaire, Tetris, WiFi Manager.
core/config.py → AppConfig is a frozen dataclass persisted to ~/.config/retrotui/config.toml.
Fields: theme, show_hidden, word_wrap_default, sunday_first, show_welcome, icon_style, hidden_icons, hidden_menu_items.
Uses tomllib on Python 3.11+, falls back to a hand-rolled minimal TOML parser for older versions.
theme.py defines 5 built-in themes as frozen dataclasses:
| Theme | Key | Desktop Pattern |
|---|---|---|
| Windows 3.1 | win31 |
spaces |
| DOS/CGA | dos_cga |
▒ |
| Windows 95 | win95 |
spaces |
| Hacker | hacker |
▓ |
| Amiga | amiga |
spaces |
Each theme defines color pairs for 256-color and 8-color terminals. utils.init_colors(theme) calls curses.init_pair() for every semantic role (desktop, menubar, window_border, etc.). utils.theme_attr(role) provides cached lookups.
core/clipboard.py maintains an internal clipboard and bridges to system clipboard tools:
- Windows:
clip.exe/powershell Get-Clipboard - Wayland:
wl-copy/wl-paste - X11:
xcliporxsel
Falls back to internal-only if no system tool is found. Backend detection is cached.
| Platform | curses | PTY | Mouse |
|---|---|---|---|
| Linux/WSL | stdlib curses |
pty.fork() |
GPM (TTY) or xterm protocol |
| Windows | windows-curses |
pywinpty (ConPTY) |
xterm protocol |
core/win_termios.py: Minimaltermiosshim usingctypes.windll.kernel32(GetConsoleMode/SetConsoleMode), sobootstrap.disable_flow_control()works cross-platform.core/terminal_session.py: Dual backend — tries POSIX first, falls back topywinpty. All methods (read,write,resize,close,send_signal,poll_exit) branch onself._win_pty is not None.
~950 tests, all using unittest.TestCase + unittest.mock. No pytest dependencies.
Tests inject a fake curses module into sys.modules["curses"] before importing application code. The fake provides:
- All key code constants (
KEY_UP,KEY_MOUSE, etc.) - All mouse bitmask constants (
BUTTON1_PRESSED, etc.) - All attribute constants (
A_BOLD,A_REVERSE, etc.) - No-op implementations of
init_pair,start_color,mousemask, etc. color_pair = lambda value: int(value) * 10
The shared implementation is in tests/_support.py. Some test files define their own inline version following the same pattern.
test_<module>.py— unit tests for one module.test_<app>_<aspect>.py— tests for a specific aspect of an app (the file manager has 15+ test files).test_core_app.py— integration-level tests for theRetroTUIfacade.
python -m pytest tests/ -x # Stop on first failure
python -m pytest tests/ -q # Quiet output
python -m unittest discover tests # Also works, no pytest neededSeveral apps use class-level dicts mapping key codes to method name strings, dispatched via getattr():
_KEY_DISPATCH = {
curses.KEY_UP: "_key_up",
curses.KEY_DOWN: "_key_down",
"\x13": "_key_save", # Ctrl+S
}
def handle_key(self, key):
handler = self._KEY_DISPATCH.get(key)
if handler:
return getattr(self, handler)()Used in: Notepad, File Manager (_MENU_ACTION_MAP).
The RetroTUI facade uses lazy init for optional subsystems:
@property
def event_bus(self):
if not hasattr(self, "_event_bus"):
self._event_bus = EventBus()
return self._event_busModule-level caches with a sentinel object:
_BACKENDS_UNSET = object()
_CACHE = {"value": _BACKENDS_UNSET}
def _resolve_backend():
if _CACHE["value"] is not _BACKENDS_UNSET:
return _CACHE["value"]
# ... resolve ...
_CACHE["value"] = result
return result
def _reset_cache(): # For tests
_CACHE["value"] = _BACKENDS_UNSETUsed in: terminal_session.py, clipboard.py.
All curses write calls go through utils.safe_addstr(), which clamps coordinates to terminal bounds and silently catches curses errors. This prevents crashes when windows are near screen edges or during resize transitions.
RETROTUI_DEBUG=1— enables verbose mouse-trace logging.RETROTUI_PROFILE=1— tracks loop iterations, draw time, dispatch time, and input wait time per interval.RETROTUI_MOUSE_BACKEND=gpm|sgr|fallback— override mouse protocol detection.RETROTUI_PLUGIN_DIR=/path— override plugin search directory.RETROTUI_PLUGIN_PATH=/path1:/path2— additional plugin search paths.