From 6ad25ca5fd7558572596bac469294581e16850c0 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 10:07:17 -0500 Subject: [PATCH 1/3] feat(compose): F1 opens a full editor for writing a post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command line is a command line: it grows and wraps, but ⏎ sends, so anything longer than a couple of sentences is written somewhere else and pasted in. F1 opens a full-screen editor instead — undo, find, selection, mouse and a caret over wrapped rows — and sends the result as one command. This is the one place `MultilineEditControl` is right. It is ruled out for the command line precisely because ⏎ there has to send rather than insert; a composer is the opposite case, so none of that behaviour is written again. What is sent (`ComposeMessage`, Core): the buffer is the whole command, verb and all, with its line breaks joined by `%r` — a MUSH stores a post as one string and renders the breaks itself, which is what `+bbpost` and `@mail` bodies want. Blank rows at the ends are where the caret was left and are dropped; interior ones are paragraph breaks and survive as `%r%r`. ⌥L switches escaping. In `literal` the body's `% [ ] { } ;` and `\` are escaped so the post shows what was typed; in `as typed` nothing is. The escaping runs per line *before* the breaks are joined in, so the `%r` this writes is never itself escaped — the other order produces `%%r`, which posts the characters "%r" instead of a line break on every line. Modal, which is what makes it possible: `PinFocusToArmedBar` would fight an editor needing real focus for ever, and it stands down while the main window is inactive. It refuses over an open settings screen — that overlay takes paste off the driver because its screens have no focusable target, and this one is exactly such a target, so both would fire on a single paste. Drafts are per character, in memory for the life of the run. Keyed by the window's owner, never `_active`. Closing raises the event that stores one, so sending closes first and forgets after. F1 is claimed in MacroKeys.AppShortcuts and answered before the settings screen lookup, so a future unclaimed F-key cannot silently open this. That takes F1 off MacroKeys.Bindable, which moved one macro test to F12. New views `compose` and `compose-literal`. Build clean, 0 warnings; Core 864, Tui 1542, Graphics 83, Scripting 42, Web 37 — all passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 43 +++ .../Commands/CommandCatalog.cs | 6 + .../Commands/ComposeMessage.cs | 175 +++++++++ src/SharpMUTerm.Tui/ComposeOverlay.cs | 365 ++++++++++++++++++ src/SharpMUTerm.Tui/DemoScene.cs | 8 + src/SharpMUTerm.Tui/MacroKeys.cs | 5 + src/SharpMUTerm.Tui/Program.cs | 7 + src/SharpMUTerm.Tui/SharpMUTermApp.cs | 157 ++++++++ .../Commands/ComposeMessageTests.cs | 96 +++++ .../ComposeWindowTests.cs | 311 +++++++++++++++ .../MacroKeyCaptureTests.cs | 4 +- .../PaneActivationTests.cs | 14 +- .../SnapshotViewEmptyConfigTests.cs | 2 +- 13 files changed, 1186 insertions(+), 7 deletions(-) create mode 100644 src/SharpMUTerm.Core/Commands/ComposeMessage.cs create mode 100644 src/SharpMUTerm.Tui/ComposeOverlay.cs create mode 100644 tests/SharpMUTerm.Core.Tests/Commands/ComposeMessageTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 91c04c3..2fe9eb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,6 +213,46 @@ fallbacks) for inline images/maps. leaves, so several auto-connects always land you in the same place, and a black-holed host cannot hold the other worlds' windows hostage. `ConnectAtStartup` is *not* `AutoLogin` — one opens the socket, the other sends the connect line once one is open, and either is useful alone. +- **F1 is the composer: a full-screen editor for writing a post, sent as one command** + (`ComposeOverlay`, Tui; `ComposeMessage`, Core; ⌃P ▸ *Compose a post*; `--view compose` / + `compose-literal`). It is **the one place `MultilineEditControl` is right**. CLAUDE.md rules that + control out of the *command line* because ⏎ there has to send rather than insert; a composer is the + opposite case, so undo, find, selection, mouse and a caret over wrapped rows all come free instead of + being written again. + - **The buffer is the whole command**, verb and all — nothing is prepended and nothing is guessed at — + and its line breaks are joined with `%r`, because a MUSH stores a post as one string and renders the + breaks itself. Blank rows at the ends are where the caret was left and are dropped; interior ones are + paragraph breaks and become `%r%r`. + - **⌥L switches escaping, and the escaping runs *before* the join.** In `literal` the body's `%`, `[`, + `]`, `{`, `}`, `;` and `\` are escaped so the post shows what was typed; in `as typed` nothing is. + Escaping after the join would produce `%%r` — the characters "%r" posted into the body instead of a + line break, on every line of every literal post. The mode travels with the draft. + - **It is modal, and that is what makes it possible at all.** `PinFocusToArmedBar` would fight an editor + needing real focus for ever — except it stands down while the main window is inactive, which a modal + guarantees. Same reason the settings screens are modal. + - **Paste is the framework's here, and must stay the only path.** `SettingsOverlay` takes paste off the + *driver* because its screens have no focusable target, and its own remarks warn that a focusable + `IPasteTarget` would make both fire. `MultilineEditControl` is one. That is why F1 **refuses over an + open settings screen** (and why two modal windows with two `PreviewKeyPressed` handlers could not be + driven headlessly anyway). + - **The editor is sized from the driver, not by `Fill` alone.** `VerticalAlignment.Fill` reads arranged + bounds only once arranged, and the first frame is laid out against the control's ten-row default — a + maximised window with a ten-row editor and the footer immediately under it. `FitEditor` sets + `ViewportHeight` from `ConsoleDriver.ScreenSize`, and re-runs on `ScreenResized`. Its colours must set + **both** pairs: the control paints from the *focused* pair, and it always has focus here, so setting + only `BackgroundColor` leaves the framework's grey on screen. + - **Drafts are per character and in memory only**, for the life of the run. Not on disk deliberately: a + post is a few minutes' work, and a file would be a fourth thing this client writes, a purge entry, a + `--help` line and somebody's unsent post in their home directory. Keyed by the *window's* owner, never + `_active`; a window belonging to no connection keeps none. **`Close()` raises `Closed`, which is what + stores the draft, so a send must close first and forget after** — the other order posts the text and + hands it straight back next time the window opens. + - **F1 is claimed in `MacroKeys.AppShortcuts` but is not a settings screen**, so `ShortcutAction` answers + it *before* the screen lookup — an arm reached only on a miss would make every future unclaimed F-key + silently open the composer. Claiming it takes it off `MacroKeys.Bindable` automatically, which is why + a macro test that used F1 as "a free function key" had to move to F12. + - **The footer names ⌃S, ⌥L and Esc and nothing else.** No `⌃F find`: the control has a find *API* and + no chord bound to it, and this screen is held to the same honesty rule as the settings screens. - **Every `[link=…]` payload a pane carries is scheme-tagged by `InteractionKind`** (`LinkPayload`: `mux:send:` / `mux:prompt:` / `mux:web:`), and the panes' handler takes the *window id* the click came from. Both are security properties, not tidiness. The tagging is disjoint because the @@ -274,6 +314,9 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg `CharacterDefinition`s; the **demo config carries none**, because `PaneTint.None` is the default and a tinted demo would make every other frame in the gallery show a state most clients are not in), `deletions`, + `compose`/`compose-literal` (the F1 composer in each of its two escaping modes — the pair exists + because ⌥L changes what is *sent* and only the header says which way it is set; the demo has no + session, so the target is handed in and pinned against the live writer by `ComposeWindowTests`), `mssp`/`mssp-none`/`mssp-never` (the **three** states of the F5 ▸ `i` server-information report — a report, a server that answered and publishes none, and a world nothing has dialled; all three reached by driving the real `i` into a real F5, and all three needed because the two empty ones are diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 71a2882..3bf66cf 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -210,6 +210,12 @@ public static IReadOnlyList Build( // has a row even though each has an F-key. items.Add(new CommandItem( CommandGroup.Terminal, "Search command history", "term:history", "⌃R")); + + // The composer. Named here as well as bound to F1 for the reason the row above it is: a surface + // nobody can find is a surface nobody uses, and this one is not something a reader would guess + // at from the command line in front of them. + items.Add(new CommandItem( + CommandGroup.Terminal, "Compose a post", "term:compose", "F1 · a full editor, sent as one line")); items.Add(context.TimestampsOn ? new CommandItem(CommandGroup.Terminal, "Hide timestamps", "term:timestamps-off") : new CommandItem(CommandGroup.Terminal, "Show timestamps", "term:timestamps-on")); diff --git a/src/SharpMUTerm.Core/Commands/ComposeMessage.cs b/src/SharpMUTerm.Core/Commands/ComposeMessage.cs new file mode 100644 index 0000000..6701ade --- /dev/null +++ b/src/SharpMUTerm.Core/Commands/ComposeMessage.cs @@ -0,0 +1,175 @@ +using System.Text; + +namespace SharpMUTerm.Core.Commands; + +/// +/// How a body typed in the composer becomes the one line that goes to the game. +/// +/// A MUSH stores a post as a single string and renders its line breaks itself, so what +/// +bbpost 12=… or @mail bob=… wants is one command whose breaks are written %r — +/// not a line per row of the editor. This turns the editor's buffer into exactly that, and it is the +/// whole of what the composer sends: the buffer is the command, verb and all, so nothing is +/// prepended and nothing is guessed at. +/// +/// +/// It is pure and lives in Core because it is the part worth asserting: every interesting case here is +/// a string in and a string out, and none of it needs a terminal. +/// +/// +public static class ComposeMessage +{ + /// MUSH's line break, which is what a break in the editor becomes. + public const string LineBreak = "%r"; + + /// + /// The characters protects, each by the escape MUSH reads + /// them by: % doubles, and the rest take a backslash. + /// + /// The set is the one a MUSH's parser acts on in a command argument — a substitution (%), a + /// function or attribute evaluation ([, ]), a command separator (;), and the + /// braces that group an argument ({, }). \ is in it because it is the escape + /// itself: left alone, a backslash the writer typed would eat the character after it. + /// + /// + private static readonly char[] Specials = ['\\', '%', '[', ']', '{', '}', ';']; + + /// + /// The one line to send for , or null when there is nothing to send. + /// + /// Null rather than an empty string, and the caller refuses on it: sending an empty command to a + /// MUSH is not nothing — it is a blank line, which some games answer and all of them log. + /// + /// + public static string? Build(string? body, ComposeEscaping escaping) + { + if (string.IsNullOrEmpty(body)) + { + return null; + } + + var lines = Split(body); + Trim(lines); + if (lines.Count == 0) + { + return null; + } + + var sb = new StringBuilder(body.Length + (lines.Count * LineBreak.Length)); + for (var i = 0; i < lines.Count; i++) + { + if (i > 0) + { + sb.Append(LineBreak); + } + + // Escaping happens per line, *before* the breaks are joined in — so the %r this writes is + // never itself escaped. Doing it the other way round produces `%%r`, which posts the + // characters "%r" into the body instead of a line break, on every line of every literal + // post. The ordering is the whole correctness of this function. + sb.Append(escaping == ComposeEscaping.Literal ? Escape(lines[i]) : lines[i]); + } + + return sb.ToString(); + } + + /// + /// Splits a buffer into lines, treating CRLF, LF and a lone CR alike. The editor writes + /// , a paste carries whatever the source had, and a body that + /// travelled through either must break in the same places. + /// + private static List Split(string body) + { + var lines = new List(); + var start = 0; + for (var i = 0; i < body.Length; i++) + { + if (body[i] is not ('\n' or '\r')) + { + continue; + } + + lines.Add(body[start..i]); + if (body[i] == '\r' && i + 1 < body.Length && body[i + 1] == '\n') + { + i++; + } + + start = i + 1; + } + + lines.Add(body[start..]); + return lines; + } + + /// + /// Drops blank rows at both ends and keeps every one in between. A trailing blank line is where the + /// caret was left, not part of the post; an interior one is a paragraph break and becomes + /// %r%r, which is what the writer typed and what the game will render. + /// + private static void Trim(List lines) + { + while (lines.Count > 0 && lines[^1].Trim().Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + while (lines.Count > 0 && lines[0].Trim().Length == 0) + { + lines.RemoveAt(0); + } + } + + /// Escapes one line's MUSH metacharacters. See for the set. + private static string Escape(string line) + { + if (line.IndexOfAny(Specials) < 0) + { + return line; + } + + var sb = new StringBuilder(line.Length + 8); + foreach (var c in line) + { + switch (c) + { + case '%': + sb.Append("%%"); + break; + + case '\\' or '[' or ']' or '{' or '}' or ';': + sb.Append('\\').Append(c); + break; + + default: + sb.Append(c); + break; + } + } + + return sb.ToString(); + } +} + +/// +/// What the composer does with the MUSH metacharacters in a body — the window's own toggle. +/// +/// Two modes rather than one because both are right for different posts and neither is right for both. +/// A prose post full of 100% and [brackets] wants them to arrive as themselves; a post +/// that deliberately carries ansi(), a %r of its own or an attribute evaluation wants the +/// game to read them. Guessing which is which from the text is not possible, so the writer says. +/// +/// +public enum ComposeEscaping +{ + /// + /// Send what was typed. Only the line breaks become %r; every other character reaches the + /// game as itself and the game does what it does with it. + /// + AsTyped, + + /// + /// Protect the body, so the post shows the characters that were typed. See + /// for the set and why the escaping precedes the join. + /// + Literal, +} diff --git a/src/SharpMUTerm.Tui/ComposeOverlay.cs b/src/SharpMUTerm.Tui/ComposeOverlay.cs new file mode 100644 index 0000000..a66ea06 --- /dev/null +++ b/src/SharpMUTerm.Tui/ComposeOverlay.cs @@ -0,0 +1,365 @@ +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Layout; +using SharpMUTerm.Core.Commands; +using static SharpMUTerm.Tui.MarkupText; + +namespace SharpMUTerm.Tui; + +/// What a composed post is, at the moment it is sent or put away. +/// The editor's buffer, exactly as typed. +/// The window's escaping mode when it happened. +internal readonly record struct ComposeResult(string Body, ComposeEscaping Escaping); + +/// +/// The composer: a full-screen editor for writing a post before sending it, over the workspace. +/// +/// Why an editor and not a bigger command line. The command line is deliberately ours +/// () because ⏎ has to send rather than insert, which rules the framework's +/// text controls out for it. A composer is the opposite case and the one +/// was built for: ⏎ inserts, the buffer is a document, and undo, +/// find, selection, mouse and a caret over wrapped rows all come with it rather than being written +/// again here. +/// +/// +/// Why a modal window. The app pins keyboard focus to the armed command line on every focus +/// change, which an editor needing real focus would fight forever. It does not have to: the pin stands +/// down while the main window is inactive (PinFocusToArmedBar returns on +/// !_window.GetIsActive()), and a modal deactivates it. That is the same reason the settings +/// screens are modal, and it is why this window can simply hold a focusable control and let the +/// framework route typing, selection and paste to it. +/// +/// +/// Paste is the framework's here, deliberately. takes paste off +/// the driver because its screens are markup with no focusable target, and its own remarks warn that a +/// focusable IPasteTarget would make both paths fire. is +/// exactly such a target, so this window subscribes to nothing: the framework hands a paste to the +/// focused control and the editor inserts it as one atomic block. The two overlays are kept from being +/// open at once for that reason among others — see SharpMUTermApp.ToggleComposer. +/// +/// +internal sealed class ComposeOverlay +{ + private readonly ConsoleWindowSystem _system; + + private Window? _window; + private MultilineEditControl? _editor; + private ComposeEscaping _escaping; + private string _target = string.Empty; + private bool _canSend; + + internal ComposeOverlay(ConsoleWindowSystem system) => _system = system; + + /// Raised when ⌃S sends. The host decides where it goes and what it does about failure. + internal event EventHandler? Send; + + /// + /// Raised as the window goes away, carrying the buffer so the host can keep the draft. Raised on + /// every close — Esc, the F-key, and a send — because the host's draft store is the only thing that + /// remembers, and a close that stayed silent would lose the post. + /// + internal event EventHandler? Closed; + + internal bool IsOpen => _window is not null; + + /// The buffer as it stands, or empty when the window is shut. Internal for the tests. + internal string Body => _editor?.GetContent() ?? string.Empty; + + /// The escaping mode as it stands — the window's own toggle, not a saved setting. + internal ComposeEscaping Escaping => _escaping; + + /// The header row's markup, so a test can read who the window says it will send to. + internal string HeaderMarkup => Header(); + + /// + /// Opens the composer on , or closes it when it is already open — the same + /// toggle every other surface in this client is on, so the key that opened it puts it away. + /// + /// Who this will send to, for the header. Empty when nothing is connected. + /// The character's kept draft, or empty. + /// The mode that draft was last left in. + /// + /// Whether there is a connection to send to. False still opens the window — writing a post with the + /// world briefly down is a real thing to be doing — and refuses at the moment of sending instead, + /// which is the same rule ⏎ follows on the command line. + /// + internal void Toggle(string target, string draft, ComposeEscaping escaping, bool canSend) + { + if (_window is not null) + { + Close(); + return; + } + + _target = target; + _escaping = escaping; + _canSend = canSend; + + _editor = new MultilineEditControl + { + WrapMode = WrapMode.WrapWords, + ShowLineNumbers = false, + IsEditing = true, + + // Fill, so the editor is the window rather than the control's ten-row default sitting in the + // top corner of a maximised one — GetEffectiveViewportHeight only reads the arranged bounds + // when the alignment asks it to. Stretch for the same reason horizontally: this framework's + // controls self-size to their content unless told to fill, which on an empty buffer is a + // window you cannot see the caret in. + VerticalAlignment = VerticalAlignment.Fill, + HorizontalAlignment = HorizontalAlignment.Stretch, + + // Esc closes the window, so it must not first be spent dropping the editor from edit mode + // into browse mode — which would make the key mean two different things depending on a + // state the writer has no reason to be tracking. + EscapeExitsEditMode = false, + PlaceholderText = "Write your post. ⏎ starts a new line; the game gets one command with %r breaks.", + + // The screens' own edit colours, so the composer reads as part of this client rather than as + // the control's grey default sitting in a themed window. Both pairs are set: this control + // paints from the *focused* pair whenever it has focus, and it always has focus here, so + // setting only the unfocused one leaves the default grey on screen for the whole session — + // which is exactly what the first frame of this window showed. + BackgroundColor = new Color(ScreenPalette.EditBg), + FocusedBackgroundColor = new Color(ScreenPalette.EditBg), + ForegroundColor = new Color(ScreenPalette.Value), + FocusedForegroundColor = new Color(ScreenPalette.Value), + SelectionBackgroundColor = new Color(ScreenPalette.CursorBg), + SelectionForegroundColor = new Color(ScreenPalette.Value), + }; + + _editor.SetContent(draft); + FitEditor(); + + _window = new WindowBuilder(_system) + .AsModal() + .Maximized() + .Frameless() + .WithColors(new Color(ScreenPalette.PanelFg), new Color(ScreenPalette.PanelBg)) + .AddControl(HeaderBand()) + .AddControl(_editor) + .AddControl(FooterBand()) + .OnClosed((_, _) => Reset()) + .Build(); + + _window.PreviewKeyPressed += OnKey; + _system.ConsoleDriver.ScreenResized += OnScreenResized; + _system.AddWindow(_window); + + // Nothing focuses a control for you in this framework, and an unfocused editor takes no + // keystrokes at all — the window would open looking right and swallow everything typed into it. + _window.FocusManager.SetFocus(_editor, FocusReason.Programmatic); + } + + /// + /// Feeds one key through the very handler PreviewKeyPressed raises, so a headless test and a + /// snapshot can drive this window. The framework only pumps input inside Run(), which neither + /// enters — the same reason exists. + /// + internal bool SimulateKey(ConsoleKeyInfo key) + { + var args = new KeyPressedEventArgs(key, false); + OnKey(this, args); + return args.Handled; + } + + /// Types text into the editor, the way the framework's own key path would. + internal void SimulateTyping(string text) + { + if (_editor is null) + { + return; + } + + _editor.SetContent(_editor.GetContent() + text); + } + + private void OnKey(object? sender, KeyPressedEventArgs e) + { + if (_window is null) + { + return; + } + + var key = e.KeyInfo; + var ctrl = key.Modifiers.HasFlag(ConsoleModifiers.Control); + var alt = key.Modifiers.HasFlag(ConsoleModifiers.Alt); + + // ⌃S sends. Safe here for the reason ⌃Q is safe as the quit chord: TerminalRawMode clears IXON, + // so this is not the terminal's flow-control stop. The editor's own Ctrl table (A/C/X/Z/D/Y) + // does not claim it, and everything it does not claim bubbles up — but this handler runs first + // in any case, so the two can never disagree about who gets it. + if (ctrl && key.Key == ConsoleKey.S) + { + e.Handled = true; + SendNow(); + return; + } + + // ⌥L flips the escaping. Alt rather than Ctrl by this codebase's rule — ESC + a printable byte + // arrives intact, while half the Ctrl alphabet collapses onto bytes a terminal already spends. + if (alt && key.Key == ConsoleKey.L) + { + e.Handled = true; + _escaping = _escaping == ComposeEscaping.Literal ? ComposeEscaping.AsTyped : ComposeEscaping.Literal; + Redraw(); + return; + } + + if (key.Key == ConsoleKey.Escape && !ctrl && !alt) + { + e.Handled = true; + Close(); + } + + // Everything else is the editor's, including ⏎, the arrows, ⌃Z and a selection drag. + } + + private void SendNow() + { + if (_editor is null) + { + return; + } + + // The window closes on a send, and the host clears the draft when it accepts one. Raised before + // the close so the handler sees the buffer rather than an empty window — and Closed still + // follows, so a send the host refuses (no connection) keeps the post rather than dropping it on + // the floor. + Send?.Invoke(this, new ComposeResult(_editor.GetContent(), _escaping)); + } + + /// Shuts the window, handing the buffer back for the host to keep. + internal void Close() + { + if (_window is not { } window) + { + return; + } + + var result = new ComposeResult(Body, _escaping); + _system.CloseModalWindow(window); + Closed?.Invoke(this, result); + } + + /// + /// Repaints the bands after the mode changed. Only the two chrome rows are rebuilt: the editor is + /// the control holding the text, the caret and the undo stack, and replacing it to redraw a header + /// would throw all three away. + /// + private void Redraw() + { + if (_window is not { } window || _editor is null) + { + return; + } + + window.ClearControls(); + window.AddControl(HeaderBand()); + window.AddControl(_editor); + window.AddControl(FooterBand()); + window.Invalidate(redrawAll: true); + window.FocusManager.SetFocus(_editor, FocusReason.Programmatic); + } + + /// + /// The two chrome rows, pinned to the top and bottom edges. Sticky rather than flowed because + /// vertical space at a window's root is measured sticky-first and Fill-last: the editor takes what + /// is left, so a footer that flowed would ride up under it on a short buffer and sit in the middle + /// of the window. + /// + private MarkupControl HeaderBand() + { + var band = ScreenChrome.Band(Header(), ScreenPalette.HeaderBg); + band.StickyPosition = StickyPosition.Top; + return band; + } + + private MarkupControl FooterBand() + { + var band = ScreenChrome.Band(Footer(), ScreenPalette.FooterBg); + band.StickyPosition = StickyPosition.Bottom; + return band; + } + + /// + /// The title row: what this is, who it sends to, and which mode it is in. The target is escaped + /// because a character's name is configuration and a world's name can be anything. + /// + private string Header() + { + var target = _target.Length == 0 + ? $"[{ScreenPalette.Warn}]no connection[/]" + : $"[{ScreenPalette.Value}]{Escape(_target)}[/]"; + + var mode = _escaping == ComposeEscaping.Literal + ? $"[{ScreenPalette.Accent}]literal[/]" + : $"[{ScreenPalette.Value}]as typed[/]"; + + return $"[{ScreenPalette.Accent}]COMPOSE[/][{ScreenPalette.Label}] to [/]{target}" + + $"[{ScreenPalette.Label}] · text [/]{mode}"; + } + + /// + /// The key row. It names ⌃S, ⌥L and Esc and nothing else, because this screen is held to the same + /// honesty rule as the settings screens: every key printed here works, and none that works is + /// hidden. There is deliberately no ⌃F find — the control has a find API but no chord bound + /// to it, and advertising one would send a reader to press a key that does nothing. + /// + private string Footer() + { + var send = _canSend + ? $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] send[/]" + : $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] send (nothing connected)[/]"; + + return send + + $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]⌥L[/][{ScreenPalette.Label}] escaping[/]" + + $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]Esc[/][{ScreenPalette.Label}] close (keeps the draft)[/]"; + } + + /// + /// Sizes the editor to the window: every row the terminal has, less the two chrome bands. + /// + /// Explicitly, because VerticalAlignment.Fill alone is not enough here — the control reads + /// its arranged bounds only once it has been arranged, and the first frame is laid out + /// against its ten-row default, which is what a maximised window holding a ten-row editor and a + /// footer immediately under it looked like. The height comes from the driver rather than a literal, + /// which is the rule the header already learned: the driver knows the terminal's size before any + /// window does, and a literal is right until somebody runs this in a shorter terminal. + /// + /// + private void FitEditor() + { + if (_editor is null) + { + return; + } + + _editor.ViewportHeight = Math.Max(1, _system.ConsoleDriver.ScreenSize.Height - ChromeRows); + } + + /// The header and footer bands, which the editor's height is whatever is left over from. + private const int ChromeRows = 2; + + /// + /// Re-fits the editor when the terminal changes size. Without it a window opened at one height keeps + /// that height for as long as it is up — and a composer is a window somebody sits in for minutes, + /// which is exactly long enough to resize a terminal underneath it. + /// + private void OnScreenResized(object? sender, SharpConsoleUI.Helpers.Size size) => + _system.EnqueueOnUIThread(() => + { + FitEditor(); + _window?.Invalidate(redrawAll: true); + }); + + private void Reset() + { + // The driver outlives every window, so this comes off with the one it was put on for — the same + // rule (and the same defect avoided) as SettingsOverlay's paste hook. + _system.ConsoleDriver.ScreenResized -= OnScreenResized; + _window = null; + _editor = null; + } +} diff --git a/src/SharpMUTerm.Tui/DemoScene.cs b/src/SharpMUTerm.Tui/DemoScene.cs index 9ed9fa9..f821d27 100644 --- a/src/SharpMUTerm.Tui/DemoScene.cs +++ b/src/SharpMUTerm.Tui/DemoScene.cs @@ -19,6 +19,14 @@ internal static class DemoScene /// The world.character the demo resumes as focused/connected. public const string ActiveSessionKey = "Aetherfall.Corvid"; + /// + /// The character the demo is focused on, as a session's own title would spell it — the half of + /// after the dot. Named here so a frame that has to fake something a + /// live session would supply (the composer's target) says the same word the live writer would; + /// ComposeWindowTests holds the two together. + /// + public static string MainCharacterName => ActiveSessionKey[(ActiveSessionKey.IndexOf('.') + 1)..]; + /// /// The demo's Chat spawn window. Spelt once, here, because a spawn window's id names its /// owner as well as its target — the saved workspace, the snapshot's spawn view and diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index 2b6da3c..0e04abd 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -146,6 +146,11 @@ private static AppShortcut[] BuildAppShortcuts() // are read off. new(ConsoleModifiers.Alt, ConsoleKey.J, "goes to the next character"), new(ConsoleModifiers.Alt, ConsoleKey.K, "goes to the previous character"), + // F1 is the composer and not a settings screen, which is why it sits ahead of the F2–F9 block + // rather than in it: those keys all open the same overlay from one table, and this one opens a + // different surface. It is claimed here for the same reason they are — F4 reads this list to say + // which chords a macro can never fire on, and a key taken elsewhere would make that answer wrong. + new((ConsoleModifiers)0, ConsoleKey.F1, "opens the composer"), new((ConsoleModifiers)0, ConsoleKey.F2, "opens Triggers"), new((ConsoleModifiers)0, ConsoleKey.F3, "opens Aliases"), new((ConsoleModifiers)0, ConsoleKey.F4, "opens this screen"), diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 57db260..5141327 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -285,6 +285,13 @@ private static void WriteUsage(TextWriter usage) usage.WriteLine("character's saved password and connect line — F5's 'login' row says which."); usage.WriteLine(); usage.WriteLine("In-app: Up/Down history · Ctrl+N next window · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit."); + // The composer earns a line of its own because what it *sends* is not guessable from the window: + // the buffer is one command and its line breaks are written %r, which is what a MUSH board or + // mail body wants. Naming the send chord matters for the same reason — Ctrl+Enter is what a + // reader will try, and no Unix terminal reports it distinctly. + usage.WriteLine("Write: F1 opens a full-screen editor for a post. The whole buffer is one command and"); + usage.WriteLine(" its line breaks are sent as %r; Ctrl+S sends, Alt+L switches between sending the"); + usage.WriteLine(" text as typed and escaping it, Esc closes and keeps the draft for that character."); usage.WriteLine("Scroll: PgUp/PgDn a page · Shift+Up/Down a line · Ctrl+Home top · Ctrl+End back to live output."); usage.WriteLine("Focus: Ctrl+Left/Right/Up/Down move between panes (Ctrl+Down at the bottom reaches the second"); usage.WriteLine(" command line); Ctrl+O cycles them; Tab switches command lines. The pane you are on and"); diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 84e8255..a26400a 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -224,6 +224,22 @@ private sealed class SizeReport private readonly CommandPalette _palette; private readonly SettingsOverlay _settings; + /// The F1 composer — a full editor for a post, sent as one line. See . + private readonly ComposeOverlay _composer; + + /// + /// The unfinished post each character is holding, by session key, for the life of this run. + /// + /// Per character rather than per window, because a post is addressed to a game — the same + /// draft should come back whether you left it from the main window or a capture that character owns. + /// In memory rather than on disk deliberately: a post is a few minutes' work and closing the client + /// is a decision, so this earns none of what a file costs — a fourth thing this client writes, a + /// purge entry, a line in --help, and someone's unsent post sitting in their home directory. + /// A window with no owner keeps no draft; there is nothing to key it by. + /// + /// + private readonly Dictionary _composeDrafts = new(StringComparer.Ordinal); + /// /// The settings overlay, so a headless test can drive a key into an open screen and ask what /// happened. It is the same seam SimulateKey exists for and for the same reason: the @@ -607,6 +623,9 @@ public SharpMUTermApp( _palette = new CommandPalette(_system, BuildCatalog, () => _active?.SessionKey, id => DispatchCommand(id)); _messageLog = new MessageLogOverlay(_system, _diagnostics); _settings = new SettingsOverlay(_system); + _composer = new ComposeOverlay(_system); + _composer.Send += (_, result) => SendComposed(result); + _composer.Closed += (_, result) => KeepComposeDraft(result); _quit = new QuitOverlay(_system, QuitFactsNow, Quit); // The ⌃B which-key panel. Its facts are read at the moment it opens, so it explains the workspace @@ -785,6 +804,28 @@ public string RenderSnapshot(string? view = null) RebuildPaneArea(); } + // The composer, in both of its escaping modes — the one thing about that window a reader cannot + // otherwise see, since ⌥L changes what is *sent* and only the header says which way it is set. + // + // The target is handed in rather than resolved, because the demo scene holds no live session: + // this is the same fake the status identity already is (`_statusIdentity`), and it is held to the + // live writer by ComposeWindowTests, which asserts that a real session's composer header reads + // what SessionTitle produces. The body is prose with a '%' and brackets in it on purpose — it is + // the text whose two renderings differ. + if (string.Equals(view, "compose", StringComparison.OrdinalIgnoreCase) || + string.Equals(view, "compose-literal", StringComparison.OrdinalIgnoreCase)) + { + _composer.Toggle( + DemoScene.MainCharacterName, + "+bbpost 12=The Long Winter\n\nThe caravan reached the pass at dusk, 100% frozen and\n" + + "short two horses. [Nobody] said what everyone was thinking.\n\n" + + " -- Corvid, scribe", + string.Equals(view, "compose-literal", StringComparison.OrdinalIgnoreCase) + ? ComposeEscaping.Literal + : ComposeEscaping.AsTyped, + canSend: true); + } + // Split the workspace: Aardwolf (main) stays in the left pane, the Chat window moves to the // new right pane (split moves the focused pane's non-active tabs across, per the design). if (string.Equals(view, "split", StringComparison.OrdinalIgnoreCase)) @@ -2892,6 +2933,110 @@ private void OnCommandEntered(InputBar bar, string command) /// private WorldSession? SendTarget() => WindowSession(ActiveWindowId()); + /// + /// Opens or closes the composer (F1, ⌃P ▸ Compose a post), on the draft belonging to the + /// character whose window is focused. + /// + /// It refuses while a settings screen is up rather than stacking on top of one. Two reasons, and the + /// second is the one that would have been a bug: two modal windows with two + /// PreviewKeyPressed handlers cannot be driven headlessly, which is the same call + /// and already made — and + /// SettingsOverlay takes paste off the driver while it is open, precisely because its + /// screens offer no focusable target. The composer offers one. Open at the same time, a single paste + /// would be delivered twice: once into the editor by the framework, and once into whatever field the + /// screen behind it had open. + /// + /// + private void ToggleComposer() + { + if (!_composer.IsOpen && _settings.IsOpen) + { + RefuseCommand("close the settings screen first — the composer cannot open over it"); + return; + } + + if (_composer.IsOpen) + { + _composer.Close(); + return; + } + + var windowId = ActiveWindowId(); + var session = WindowSession(windowId); + var draft = ComposeDraftKey(windowId) is { } key && _composeDrafts.TryGetValue(key, out var kept) + ? kept + : new ComposeResult(string.Empty, ComposeEscaping.AsTyped); + + _composer.Toggle( + session is null ? string.Empty : SessionTitle(session), + draft.Body, + draft.Escaping, + canSend: session is not null); + } + + /// + /// Which character a composed post belongs to: the window's own owner, never _active. Null for + /// a window that belongs to no connection — the web view — which keeps no draft, because there is + /// nothing to key one by and nowhere for it to be sent. + /// + private string? ComposeDraftKey(string windowId) => + WindowSession(windowId)?.SessionKey + ?? (_workspace.FindWindow(windowId)?.SessionKey is { Length: > 0 } owner ? owner : null); + + /// + /// Sends a composed post as one line, through the ordinary command path — so it is echoed, recorded + /// in history and alias-expanded exactly like the same text typed on the command line, which is what + /// the buffer is. The window closes on success and keeps the post on a refusal. + /// + private void SendComposed(ComposeResult result) + { + var windowId = ActiveWindowId(); + if (WindowSession(windowId) is not { } session) + { + RefuseCommand(NothingToSendTo(windowId)); + return; + } + + if (ComposeMessage.Build(result.Body, result.Escaping) is not { } line) + { + // An empty buffer is not a blank command to send: a MUSH answers a blank line, and some log + // it. Said out loud rather than closing quietly, so ⌃S on an empty window is not mistaken + // for a post that went. + RefuseCommand("nothing to send — the composer is empty"); + return; + } + + // Close first, then forget: closing raises Closed, which is what keeps a draft, so a removal + // before it is undone by the very act of shutting the window — the post came back next time it + // was opened, already sent. + _composer.Close(); + _composeDrafts.Remove(session.SessionKey); + _ = session.SendUserInputAsync(line); + } + + /// + /// Keeps what the composer was holding as it closed, or forgets the character's draft when the + /// window was left empty — so a sent-and-cleared composer does not reopen holding the last post. + /// + private void KeepComposeDraft(ComposeResult result) + { + if (ComposeDraftKey(ActiveWindowId()) is not { } key) + { + return; + } + + if (result.Body.Trim().Length == 0) + { + _composeDrafts.Remove(key); + return; + } + + _composeDrafts[key] = result; + } + + /// The composer, for the tests and the snapshot views. + internal ComposeOverlay Composer => _composer; + /// /// Why a line could not be sent, naming what would open a connection. Three states, because they need /// three different next steps: a window whose recorded owner has no session this run, a window that @@ -4291,6 +4436,15 @@ private void RegisterFocusReportTab() { if (claim.Modifiers == (ConsoleModifiers)0) { + // F1 is the one bare-key claim that is not a settings screen. Checked before the screen + // lookup rather than after it, so the "claimed but nothing runs on it" throw below stays a + // real check: an arm reached only on a miss would make every future unclaimed F-key silently + // open the composer. + if (claim.Key == ConsoleKey.F1) + { + return () => { ToggleComposer(); return true; }; + } + if (!screens.TryGetValue(claim.Key, out var open)) { return null; @@ -5609,6 +5763,9 @@ internal bool DispatchCommand(string id) case "term:history": ToggleHistorySearch(); return true; + case "term:compose": + ToggleComposer(); + return true; case "term:log-on": StartLogging(); break; diff --git a/tests/SharpMUTerm.Core.Tests/Commands/ComposeMessageTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/ComposeMessageTests.cs new file mode 100644 index 0000000..150edfd --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Commands/ComposeMessageTests.cs @@ -0,0 +1,96 @@ +using SharpMUTerm.Core.Commands; + +namespace SharpMUTerm.Core.Tests.Commands; + +/// +/// What the composer sends: one command, line breaks written the way a MUSH stores them, and the two +/// escaping modes the window's own toggle chooses between. +/// +public class ComposeMessageTests +{ + [Test] + public async Task LinesBecomeOneCommandJoinedByLineBreaks() + { + var line = ComposeMessage.Build("+bbpost 12=Title\nfirst\nsecond", ComposeEscaping.AsTyped); + + await Assert.That(line).IsEqualTo("+bbpost 12=Title%rfirst%rsecond"); + } + + /// + /// A blank row in the middle is a paragraph break and survives as one; blank rows at the ends are + /// where the caret was left and are not part of the post. + /// + [Test] + public async Task InteriorBlankLinesSurviveAndTrailingOnesDoNot() + { + var line = ComposeMessage.Build("\n\nTitle\n\nbody\n\n\n", ComposeEscaping.AsTyped); + + await Assert.That(line).IsEqualTo("Title%r%rbody"); + } + + [Test] + [Arguments("a\r\nb")] + [Arguments("a\nb")] + [Arguments("a\rb")] + public async Task EveryLineEndingBreaksInTheSamePlace(string body) => + await Assert.That(ComposeMessage.Build(body, ComposeEscaping.AsTyped)).IsEqualTo("a%rb"); + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + [Arguments("\n\n\n")] + public async Task AnEmptyBufferIsNothingToSend(string? body) => + await Assert.That(ComposeMessage.Build(body, ComposeEscaping.AsTyped)).IsNull(); + + [Test] + public async Task AsTypedChangesNothingButTheBreaks() + { + const string body = "100% sure [ok] {x}; done\nand \\ too"; + + await Assert.That(ComposeMessage.Build(body, ComposeEscaping.AsTyped)) + .IsEqualTo("100% sure [ok] {x}; done%rand \\ too"); + } + + [Test] + public async Task LiteralProtectsEveryMetacharacter() + { + var line = ComposeMessage.Build("100% sure [ok] {x}; done", ComposeEscaping.Literal); + + await Assert.That(line).IsEqualTo("100%% sure \\[ok\\] \\{x\\}\\; done"); + } + + /// + /// The ordering that makes literal mode work at all: the escaping runs per line, before the breaks + /// are joined in, so the %r this writes is never itself escaped. The other way round produces + /// %%r — the characters "%r" posted into the body instead of a line break, on every line of + /// every literal post. + /// + [Test] + public async Task LiteralEscapesTheBodyAndNotTheBreaksItWrites() + { + var line = ComposeMessage.Build("50% here\n50% there", ComposeEscaping.Literal); + + await Assert.That(line).IsEqualTo("50%% here%r50%% there"); + await Assert.That(line).DoesNotContain("%%r"); + } + + /// + /// A backslash the writer typed is escaped too. Left alone it would be read as the escape itself and + /// would eat the character after it — so a\b posts as ab, which is a character quietly + /// missing from somebody's post. + /// + [Test] + public async Task LiteralEscapesABackslashSoItDoesNotEatWhatFollowsIt() + { + await Assert.That(ComposeMessage.Build("a\\b", ComposeEscaping.Literal)).IsEqualTo("a\\\\b"); + } + + [Test] + public async Task LiteralLeavesOrdinaryProseAlone() + { + const string body = "The caravan reached the pass at dusk."; + + await Assert.That(ComposeMessage.Build(body, ComposeEscaping.Literal)).IsEqualTo(body); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs new file mode 100644 index 0000000..dc6c2ca --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs @@ -0,0 +1,311 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The composer, driven the way a user drives it: F1 opens it on the focused character's draft, ⌃S +/// sends one command to that character, Esc keeps the post, and ⌥L changes what "send" means. +/// +/// The sessions are connected for the same reason +/// 's are: SendUserInputAsync returns without writing when +/// there is no live transport, so "the post reached the world" asserted against an unconnected session +/// would be true whatever the code did. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch +/// the process-global console streams. +/// +[NotInParallel] +public class ComposeWindowTests +{ + private const int Width = 120; + private const int Height = 32; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo Key(ConsoleKey key, ConsoleModifiers modifiers = 0) => + new('\0', key, modifiers.HasFlag(ConsoleModifiers.Shift), + modifiers.HasFlag(ConsoleModifiers.Alt), modifiers.HasFlag(ConsoleModifiers.Control)); + + private static ConsoleKeyInfo CtrlS => Key(ConsoleKey.S, ConsoleModifiers.Control); + + private static ConsoleKeyInfo AltL => Key(ConsoleKey.L, ConsoleModifiers.Alt); + + private static ConsoleKeyInfo Esc => Key(ConsoleKey.Escape); + + // ---- Opening and closing ---------------------------------------------------------------- + + [Test] + public async Task F1OpensTheComposerAndF1ClosesIt() + { + var world = await Connected(); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(world.App.Composer.IsOpen).IsTrue(); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(world.App.Composer.IsOpen).IsFalse(); + } + + [Test] + public async Task TheCommandSurfaceOpensItToo() + { + var world = await Connected(); + + await Assert.That(world.App.DispatchCommand("term:compose")).IsTrue(); + + await Assert.That(world.App.Composer.IsOpen).IsTrue(); + } + + /// + /// The header names the character the post will reach — resolved from the focused window, which is + /// the same rule ⏎ follows. It is asserted against SessionTitle's own output rather than the + /// string "Ann", because the snapshot demo has to fake this value and the two must not drift. + /// + [Test] + public async Task TheHeaderNamesTheCharacterItWillSendTo() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.HeaderMarkup).Contains("Ann"); + await Assert.That(world.App.Composer.HeaderMarkup).DoesNotContain("no connection"); + } + + // ---- Sending ----------------------------------------------------------------------------- + + [Test] + public async Task CtrlSSendsTheWholeBufferAsOneCommand() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("+bbpost 12=Title\nfirst\nsecond"); + + await Assert.That(world.App.Composer.SimulateKey(CtrlS)).IsTrue(); + + await Assert.That(world.Telnet.Lines).IsEquivalentTo(new[] { "+bbpost 12=Title%rfirst%rsecond" }); + await Assert.That(world.App.Composer.IsOpen).IsFalse(); + } + + [Test] + public async Task AltLFlipsTheEscapingAndChangesWhatIsSent() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("100% [sure]"); + + await Assert.That(world.App.Composer.SimulateKey(AltL)).IsTrue(); + await Assert.That(world.App.Composer.Escaping).IsEqualTo(ComposeEscaping.Literal); + await Assert.That(world.App.Composer.HeaderMarkup).Contains("literal"); + + world.App.Composer.SimulateKey(CtrlS); + + await Assert.That(world.Telnet.Lines).IsEquivalentTo(new[] { "100%% \\[sure\\]" }); + } + + [Test] + public async Task SendingAnEmptyComposerSaysSoAndSendsNothing() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + world.App.Composer.SimulateKey(CtrlS); + + await Assert.That(world.Telnet.Lines).IsEmpty(); + await Assert.That(world.App.Composer.IsOpen).IsTrue(); // the window stays; nothing was posted + await Assert.That(world.App.StatusMarkup).Contains("composer is empty"); + } + + // ---- Drafts ------------------------------------------------------------------------------ + + [Test] + public async Task EscKeepsThePostAndReopeningBringsItBack() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("half a post"); + + await Assert.That(world.App.Composer.SimulateKey(Esc)).IsTrue(); + await Assert.That(world.App.Composer.IsOpen).IsFalse(); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.Body).IsEqualTo("half a post"); + } + + /// The mode travels with the draft: reopening in the other one would send different text. + [Test] + public async Task TheEscapingModeIsKeptWithTheDraft() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("100%"); + world.App.Composer.SimulateKey(AltL); + world.App.Composer.SimulateKey(Esc); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.Escaping).IsEqualTo(ComposeEscaping.Literal); + } + + [Test] + public async Task ASentPostIsNotStillThereNextTime() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("posted and gone"); + world.App.Composer.SimulateKey(CtrlS); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.Body).IsEmpty(); + } + + /// + /// A draft belongs to a character, not to the client. Two open characters keep two posts, and + /// switching between them brings the right one back — the failure this guards is the one a single + /// shared buffer would produce, where a post written to one game reappears addressed to another. + /// + [Test] + public async Task EachCharacterKeepsItsOwnPost() + { + var two = await TwoConnectedWorlds(); + + two.App.SimulateKey(Key(ConsoleKey.F1)); + two.App.Composer.SimulateTyping("Bob's post"); + two.App.Composer.SimulateKey(Esc); + + await Assert.That(two.App.DispatchCommand("char:Quiet.Ann")).IsTrue(); + two.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(two.App.Composer.Body).IsEmpty(); + + two.App.Composer.SimulateTyping("Ann's post"); + two.App.Composer.SimulateKey(Esc); + await Assert.That(two.App.DispatchCommand("char:Loud.Bob")).IsTrue(); + two.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(two.App.Composer.Body).IsEqualTo("Bob's post"); + } + + /// + /// The post goes to the character whose window is focused, not to whichever was active last — the + /// same rule ⏎ follows, and the misdelivery this codebase has had before. + /// + [Test] + public async Task ThePostGoesToTheFocusedWindowsCharacter() + { + var two = await TwoConnectedWorlds(); + await Assert.That(two.App.DispatchCommand("char:Quiet.Ann")).IsTrue(); + + two.App.SimulateKey(Key(ConsoleKey.F1)); + two.App.Composer.SimulateTyping("for Ann"); + two.App.Composer.SimulateKey(CtrlS); + + await Assert.That(two.Quiet.Lines).IsEquivalentTo(new[] { "for Ann" }); + await Assert.That(two.Loud.Lines).IsEmpty(); + } + + // ---- Refusals ---------------------------------------------------------------------------- + + /// + /// With nothing connected the window still opens — writing a post while the world is down is a real + /// thing to be doing — and refuses at the moment of sending, keeping the post. + /// + [Test] + public async Task WithNothingConnectedItOpensAndRefusesToSend() + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(new AppConfiguration(), Headless, new HeadlessConsoleDriver(Width, Height)); + + app.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(app.Composer.IsOpen).IsTrue(); + await Assert.That(app.Composer.HeaderMarkup).Contains("no connection"); + + app.Composer.SimulateTyping("a post with nowhere to go"); + app.Composer.SimulateKey(CtrlS); + + await Assert.That(app.Composer.IsOpen).IsTrue(); + await Assert.That(app.Composer.Body).IsEqualTo("a post with nowhere to go"); + } + + /// + /// It will not open over a settings screen. Two modal windows with two PreviewKeyPressed + /// handlers cannot be driven headlessly — and, worse, SettingsOverlay listens for paste at the + /// driver precisely because its screens have no focusable target, so a paste with both open would be + /// delivered twice. + /// + [Test] + public async Task ItRefusesToOpenOverASettingsScreen() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F7)); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.IsOpen).IsFalse(); + await Assert.That(world.App.StatusMarkup).Contains("close the settings screen first"); + } + + // ---- Harness ----------------------------------------------------------------------------- + + private sealed record Wired(SharpMUTermApp App, RecordingTelnetSession Telnet); + + private static async Task Connected() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Quiet", Host = "quiet.example.org", Port = 4000 }; + world.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + config.Worlds.Add(world); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + await app.BindWorldWithoutConnecting(world).ConnectAsync(); + app.RenderNextFrame(); + return new Wired(app, telnet); + } + + private sealed record TwoWorlds( + SharpMUTermApp App, RecordingTelnetSession Quiet, RecordingTelnetSession Loud); + + private static async Task TwoConnectedWorlds() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + + var quiet = new WorldDefinition { Name = "Quiet", Host = "quiet.example.org", Port = 4000 }; + quiet.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + var loud = new WorldDefinition { Name = "Loud", Host = "loud.example.org", Port = 4000 }; + loud.Characters.Add(new CharacterDefinition { Name = "Bob", Logging = new LoggingSettings() }); + config.Worlds.Add(quiet); + config.Worlds.Add(loud); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var quietTelnet = new RecordingTelnetSession(); + var loudTelnet = new RecordingTelnetSession(); + app.TelnetFactory = options => + options.Host.StartsWith("quiet", StringComparison.Ordinal) ? quietTelnet : loudTelnet; + + await Open(app, "Quiet.Ann"); + await Open(app, "Loud.Bob"); + app.RenderNextFrame(); + return new TwoWorlds(app, quietTelnet, loudTelnet); + } + + private static async Task Open(SharpMUTermApp app, string sessionKey) + { + if (!app.DispatchCommand(Core.Commands.CommandIds.Character(sessionKey))) + { + throw new InvalidOperationException($"the app would not switch to {sessionKey}"); + } + + await app.FindSession(sessionKey)!.ConnectAsync(); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs index 452a108..0c0c9c7 100644 --- a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs @@ -56,7 +56,9 @@ private static void ArmCapture(SettingsSession session) /// [Test] [Arguments("Ctrl+F1", nameof(MacroKeyDelivery.Fires))] - [Arguments("F1", nameof(MacroKeyDelivery.Fires))] + // F1 opens the composer, so a macro bound there can never fire — the same answer F2–F9 get + // for the settings screens. F12 below is what a free function key looks like. + [Arguments("F1", nameof(MacroKeyDelivery.Taken))] [Arguments("F12", nameof(MacroKeyDelivery.Fires))] [Arguments("Shift+F3", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+K", nameof(MacroKeyDelivery.Fires))] diff --git a/tests/SharpMUTerm.Tui.Tests/PaneActivationTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneActivationTests.cs index b34e546..47cf8c0 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneActivationTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneActivationTests.cs @@ -295,18 +295,22 @@ public async Task ThePromptSaysSoWhenThePaneHasNoConnection() /// /// A macro key is a keystroke too, and it went through _active by the same route the command - /// line did — so F1 pressed with a session-less pane in front of you fired the macro of the world you - /// had left. Same rule, same resolver. + /// line did — so a macro key pressed with a session-less pane in front of you fired the macro of the + /// world you had left. Same rule, same resolver. + /// + /// The binding is F12 because it is free. It was F1 until the composer claimed that key, and a macro + /// on a claimed chord cannot fire at all — which would have made this pass for the wrong reason. + /// /// [Test] public async Task AMacroKeyInAPaneWithNoSession_FiresNothing() { var one = await ConnectedBesideASessionLessPane(); - one.App.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F1, false, false, false)); + one.App.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F12, false, false, false)); await Assert.That(one.Ann.Lines).IsEquivalentTo(new[] { "look" }); // the macro is live where it belongs MoveTo(one.App, one.SessionLessPane); - one.App.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F1, false, false, false)); + one.App.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F12, false, false, false)); await Assert.That(one.Ann.Lines).IsEquivalentTo(new[] { "look" }); // and nowhere else } @@ -590,7 +594,7 @@ private static async Task ConnectedBesideASessionLessPane() config.TriggerSets.Add(new TriggerSet { Name = "keys", - Macros = { new Macro { Name = "look", Key = "F1", Command = "look" } }, + Macros = { new Macro { Name = "look", Key = "F12", Command = "look" } }, }); var world = World("Quiet"); diff --git a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs index 9b45ac4..8cc853a 100644 --- a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs @@ -41,7 +41,7 @@ public class SnapshotViewEmptyConfigTests "focus", "focus-moved", "freeze", "freeze-scrollback", "move", "drag", "scrollback", "scrollback-up", "away", "away-scrollback", "web", "rail-long", "history", "history-search", "history-search-filter", "draft", "draft2", "menu", "menu-split", "messages", "quit", - "connections", "characters", "tint", "deletions", "textansi", "input", "keypad", "password", + "connections", "characters", "tint", "compose", "compose-literal", "deletions", "textansi", "input", "keypad", "password", "startup", "logging", "set", "triggers", "route", "highlight", "worlds", "settings", "mssp", "mssp-none", "mssp-never", ]; From ac5d45a6908641f19131cf74803872bad972d93d Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 12:13:58 -0500 Subject: [PATCH 2/3] fix(compose): the composer is a surface, and surfaces do not stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AnyOverlayOpen` is this app's statement that a modal surface owns the screen, and the composer was missing from it — so ⌃R and the other guards that read that flag acted while a post was being written. It is in the list now. F1 correspondingly refuses over *any* surface rather than only over a settings screen. That guard was written for the paste hazard specifically (SettingsOverlay listens at the driver because its screens have no focusable target, and the editor is one), but the rule it was enforcing was always the general one. The refusal names the surface in the way rather than saying "a surface", like every other refusal here. Not changed: the palette, quit prompt, message log and settings screens still open over each other, which they did before this branch. Making them mutually exclusive is a behaviour change to five existing surfaces with its own test surface, and ⌃P has to keep closing the palette it opened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 23 ++++++++++-- .../ComposeWindowTests.cs | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index a26400a..e806374 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -2949,9 +2949,12 @@ private void OnCommandEntered(InputBar bar, string command) /// private void ToggleComposer() { - if (!_composer.IsOpen && _settings.IsOpen) + // Any surface, not just a settings screen. The settings screens are the case with teeth — see the + // paste reasoning above — but the rule this app already states is that a modal surface owns the + // screen, and the composer is one, so it may not be stacked on top of another. + if (!_composer.IsOpen && AnyOverlayOpen) { - RefuseCommand("close the settings screen first — the composer cannot open over it"); + RefuseCommand($"close {OpenOverlayName()} first — the composer cannot open over it"); return; } @@ -3187,7 +3190,21 @@ private void ToggleHistorySearch() /// private bool AnyOverlayOpen => _palette.IsOpen || _settings.IsOpen || _quit.IsOpen || _messageLog.IsOpen || _historySearch.IsOpen - || _prefixPanel.IsOpen; + || _prefixPanel.IsOpen || _composer.IsOpen; + + /// + /// What is currently true because of, in the words the reader knows the + /// surface by. A refusal that named "a surface" would leave somebody looking for which one; every + /// other refusal in this client names the thing it is talking about, and this is one string. + /// + private string OpenOverlayName() => + _settings.IsOpen ? "the settings screen" + : _palette.IsOpen ? "the command surface" + : _quit.IsOpen ? "the quit prompt" + : _messageLog.IsOpen ? "the client messages" + : _historySearch.IsOpen ? "the history search" + : _prefixPanel.IsOpen ? "the pane keys panel" + : "what is open"; /// Whether either bar is holding unsent text — what the tab marker means. private bool AnyBarHasText() => diff --git a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs index dc6c2ca..7288c53 100644 --- a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs @@ -252,6 +252,41 @@ public async Task ItRefusesToOpenOverASettingsScreen() await Assert.That(world.App.StatusMarkup).Contains("close the settings screen first"); } + /// + /// The same rule for every surface, not only the settings screens. A modal surface owns the screen + /// in this client, and the composer is one — so it may not be stacked on top of another, and the + /// refusal names the one that is in the way rather than saying "a surface". + /// + [Test] + public async Task ItRefusesToOpenOverTheCommandSurface() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.P, ConsoleModifiers.Control)); + await Assert.That(world.App.MenuIsOpen).IsTrue(); + + world.App.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(world.App.Composer.IsOpen).IsFalse(); + await Assert.That(world.App.StatusMarkup).Contains("close the command surface first"); + } + + /// + /// And the other direction, which is what putting the composer into AnyOverlayOpen buys: the + /// surfaces that already decline to act while a screen is up decline while a post is being written + /// too. ⌃R is the one that reads that flag directly. + /// + [Test] + public async Task TheComposerCountsAsAnOpenSurface() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + world.App.SimulateKey(Key(ConsoleKey.R, ConsoleModifiers.Control)); + + await Assert.That(world.App.HistorySearchOpen).IsFalse(); + await Assert.That(world.App.Composer.IsOpen).IsTrue(); + } + // ---- Harness ----------------------------------------------------------------------------- private sealed record Wired(SharpMUTermApp App, RecordingTelnetSession Telnet); From ecbe0a639a52d5d43794dce53a4aba9f44cc50e6 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 13:13:44 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(compose):=20nothing=20opens=20over=20th?= =?UTF-8?q?e=20composer,=20and=20=E2=8C=83Q=20counts=20the=20post?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard was one-sided: the composer refused to open over a surface, and every surface still opened over the composer, because a global shortcut runs before any window sees the key. ⌃P, F2–F9 and ⌃P ▸ Show client messages now refuse while a post is being written, naming what is in the way and saying that Esc keeps the post. ⌃Q is deliberately not one of them. A client you cannot leave from a modal is worse than one that stacks a prompt, so the quit prompt still opens — and QuitFactsNow counts the unsent post instead, which is the answer that helps: it says what leaving would cost. The composer keeps drafts in memory only, so quitting is precisely the thing that loses them. The open window's buffer is counted separately from the stored drafts because it is not in that dictionary until it closes. Still not changed: the pre-existing surfaces open over each other as they did before this branch. Core 864, Tui 1546, Graphics 83, Scripting 42, Web 37 — all passing, build clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 57 ++++++++++++++++++- .../ComposeWindowTests.cs | 43 ++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index e806374..00b4fbe 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -3197,6 +3197,34 @@ private void ToggleHistorySearch() /// surface by. A refusal that named "a surface" would leave somebody looking for which one; every /// other refusal in this client names the thing it is talking about, and this is one string. /// + /// + /// Refuses a surface that would open over the composer, naming what is in the way. The + /// composer's own guard is the other half of this; together they make the pair mutually exclusive + /// rather than one-sided, which is the whole of the stacking rule for this feature. + /// + /// ⌃Q is deliberately not one of the callers. Quitting has to work from wherever the reader + /// is — a modal that could refuse it would be a client you cannot leave — so the quit prompt opens + /// over the composer and counts the unsent post instead, which is the + /// answer that actually helps: it says what would be lost rather than declining to ask. + /// + /// + /// The pre-existing surfaces still open over each other, as they did before the composer + /// existed. Making all of them mutually exclusive is a change to five surfaces with its own test + /// surface — and ⌃P has to keep closing the palette it opened, so it cannot simply read + /// — which is a separate piece of work rather than part of this one. + /// + /// + private bool ComposerIsInTheWay(string surface) + { + if (!_composer.IsOpen) + { + return false; + } + + RefuseCommand($"close the composer first (Esc keeps the post) — {surface} cannot open over it"); + return true; + } + private string OpenOverlayName() => _settings.IsOpen ? "the settings screen" : _palette.IsOpen ? "the command surface" @@ -4468,7 +4496,15 @@ private void RegisterFocusReportTab() } var key = claim.Key; - return () => { _settings.Toggle(key, open); return true; }; + return () => + { + if (!ComposerIsInTheWay("a settings screen")) + { + _settings.Toggle(key, open); + } + + return true; + }; } if (claim.Modifiers == ConsoleModifiers.Alt) @@ -4566,7 +4602,13 @@ private QuitFacts QuitFactsNow() var activeId = ActiveWindowId(); var holding = _workspace.Windows.Where(w => w.HasUnsentInput).ToList(); var bars = (_input.Buffer.IsEmpty ? 0 : 1) + (_second.Visible && !_second.Buffer.IsEmpty ? 1 : 0); - var drafts = holding.Count(w => w.Id != activeId) + bars; + // A post in the composer is a draft too, and the one most worth being asked about: it is minutes + // of writing rather than a line, and it is kept in memory only, so quitting is exactly the thing + // that loses it. The open window's buffer is counted separately from the stored drafts because it + // is not in that dictionary until it closes — counting the dictionary alone would say nothing + // about the post on the screen. + var posts = _composeDrafts.Count + (_composer.IsOpen && _composer.Body.Trim().Length > 0 ? 1 : 0); + var drafts = holding.Count(w => w.Id != activeId) + bars + posts; // An open settings screen is deliberately not among the facts. It used to contribute "F5 is open // — 3 unsaved edits", which was true while closing a screen could throw its edits away; now every @@ -5772,7 +5814,11 @@ internal bool DispatchCommand(string id) ToggleSecondBar(); return true; case "term:messages": - _messageLog.Toggle(); + if (!ComposerIsInTheWay("the client messages")) + { + _messageLog.Toggle(); + } + return true; case "term:restore-purge": PurgeRestoreLog(); @@ -6079,6 +6125,11 @@ private void ToggleFreeze() /// Opens/closes the command surface (⌃P or the header ☰ menu) and flips the header caret. private void ToggleMenu() { + if (ComposerIsInTheWay("the command surface")) + { + return; + } + _palette.Toggle(); _header.SetContent(new List { HeaderMarkup() }); } diff --git a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs index 7288c53..bcb7426 100644 --- a/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs @@ -287,6 +287,49 @@ public async Task TheComposerCountsAsAnOpenSurface() await Assert.That(world.App.Composer.IsOpen).IsTrue(); } + /// + /// And nothing opens over the composer either — the pair is mutually exclusive rather than + /// one-sided. Driven through the real chords and the real ⌃P entry, because a global shortcut runs + /// before any window sees the key, which is exactly how a second modal got on top of this one. + /// + [Test] + public async Task NoOtherSurfaceOpensOverTheComposer() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + world.App.SimulateKey(Key(ConsoleKey.P, ConsoleModifiers.Control)); + await Assert.That(world.App.MenuIsOpen).IsFalse(); + + world.App.SimulateKey(Key(ConsoleKey.F7)); + await Assert.That(world.App.OpenSettingsKey).IsNull(); + + world.App.DispatchCommand("term:messages"); + await Assert.That(world.App.MessageLogIsOpen).IsFalse(); + + await Assert.That(world.App.Composer.IsOpen).IsTrue(); + await Assert.That(world.App.StatusMarkup).Contains("close the composer first"); + } + + /// + /// ⌃Q is the exception, and deliberately: a client you cannot leave from a modal would be worse than + /// one that stacks a prompt. What it does instead is count the unsent post, so the question + /// says what leaving would cost — the composer keeps drafts in memory only, so quitting is the one + /// thing that loses them. + /// + [Test] + public async Task QuittingStillWorksAndCountsTheUnsentPost() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("a post worth keeping"); + + world.App.SimulateKey(Key(ConsoleKey.Q, ConsoleModifiers.Control)); + + await Assert.That(world.App.QuitPromptOpen).IsTrue(); + await Assert.That(string.Join("\n", world.App.QuitPromptLines)).Contains("unsent draft"); + } + // ---- Harness ----------------------------------------------------------------------------- private sealed record Wired(SharpMUTermApp App, RecordingTelnetSession Telnet);