diff --git a/CLAUDE.md b/CLAUDE.md index 7d12056..52ad05b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,6 +259,46 @@ fallbacks) for inline images/maps. - **The launcher is caller-supplied and null by default**, the fourth member of the `save:`/`logRoot:`/`restore:` family: **a snapshot and a test start no browser**, and an app with no opener refuses out loud (`AutoLinkTests.AnAppWithNoOpenerLaunchesNothingAndSaysSo`). +- **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 @@ -325,6 +365,9 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg The moved frame is also the one that shows a bar wearing a character's hue while its prompt reads `no connection ›`, which is the composition rule stated in paint: hue says whose, not whether), `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 1279a0d..cd14d08 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -286,6 +286,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 c3c5b84..4d09432 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 @@ -618,6 +634,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 @@ -796,6 +815,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); + } + // The reported defect, as a frame: a URL too long for the pane it arrived in. It splits first // precisely so the pane is narrower than the terminal — that is the whole bug. The emulator's own // URL detection works on the terminal *row*, so a URL wrapped inside a pane is two fragments to @@ -2972,6 +3013,113 @@ 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() + { + // 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 {OpenOverlayName()} 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 @@ -3122,7 +3270,49 @@ private void ToggleHistorySearch() /// private bool AnyOverlayOpen => _palette.IsOpen || _settings.IsOpen || _quit.IsOpen || _messageLog.IsOpen || _historySearch.IsOpen - || _prefixPanel.IsOpen; + || _prefixPanel.IsOpen || _composer.IsOpen; + + /// + /// 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; + } + + /// + /// 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() => @@ -4441,13 +4631,30 @@ 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; } 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) @@ -4545,7 +4752,19 @@ 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. + // + // The open composer's *own* character is then excluded from the stored count, because after Esc + // and F1 that post is in both places at once: the dictionary kept it on the way out and the + // window is holding it again. Counting both said "2 unsent drafts" for one post. + var openKey = _composer.IsOpen ? ComposeDraftKey(activeId) : null; + var posts = _composeDrafts.Count(d => d.Key != openKey) + + (_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 @@ -5774,7 +5993,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(); @@ -5782,6 +6005,9 @@ internal bool DispatchCommand(string id) case "term:history": ToggleHistorySearch(); return true; + case "term:compose": + ToggleComposer(); + return true; case "term:log-on": StartLogging(); break; @@ -6078,6 +6304,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.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..d99876d --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/ComposeWindowTests.cs @@ -0,0 +1,410 @@ +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"); + } + + /// + /// 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(); + } + + /// + /// 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("1 unsent draft"); + } + + /// + /// One post is one draft, however many places it is being held. After Esc and F1 the same text is in + /// the store and in the open window — the store kept it on the way out and the window is + /// holding it again — and counting both told the reader they were about to lose two things. + /// + [Test] + public async Task AReopenedPostIsCountedOnce() + { + var world = await Connected(); + world.App.SimulateKey(Key(ConsoleKey.F1)); + world.App.Composer.SimulateTyping("one post, counted once"); + world.App.Composer.SimulateKey(Esc); + world.App.SimulateKey(Key(ConsoleKey.F1)); + + world.App.SimulateKey(Key(ConsoleKey.Q, ConsoleModifiers.Control)); + + var prompt = string.Join("\n", world.App.QuitPromptLines); + await Assert.That(prompt).Contains("1 unsent draft"); + await Assert.That(prompt).DoesNotContain("2 unsent draft"); + } + + // ---- 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 5f9b754..b5b872c 100644 --- a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs @@ -41,7 +41,8 @@ public class SnapshotViewEmptyConfigTests "focus", "focus-moved", "freeze", "freeze-scrollback", "move", "drag", "scrollback", "scrollback-up", "away", "away-scrollback", "links", "web", "rail-long", "history", "history-search", "history-search-filter", "draft", "draft2", "menu", "menu-split", "messages", "quit", - "connections", "characters", "tint", "tint-input", "tint-input-moved", "deletions", "textansi", "input", "keypad", "password", + "connections", "characters", "tint", "tint-input", "tint-input-moved", + "compose", "compose-literal", "deletions", "textansi", "input", "keypad", "password", "startup", "logging", "set", "triggers", "route", "highlight", "worlds", "settings", "mssp", "mssp-none", "mssp-never", ];