Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ public static IReadOnlyList<CommandItem> 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"));
Expand Down
175 changes: 175 additions & 0 deletions src/SharpMUTerm.Core/Commands/ComposeMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using System.Text;

namespace SharpMUTerm.Core.Commands;

/// <summary>
/// How a body typed in the composer becomes the one line that goes to the game.
/// <para>
/// A MUSH stores a post as a single string and renders its line breaks itself, so what
/// <c>+bbpost 12=…</c> or <c>@mail bob=…</c> wants is one command whose breaks are written <c>%r</c> —
/// 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 <em>is</em> the command, verb and all, so nothing is
/// prepended and nothing is guessed at.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public static class ComposeMessage
{
/// <summary>MUSH's line break, which is what a break in the editor becomes.</summary>
public const string LineBreak = "%r";

/// <summary>
/// The characters <see cref="ComposeEscaping.Literal"/> protects, each by the escape MUSH reads
/// them by: <c>%</c> doubles, and the rest take a backslash.
/// <para>
/// The set is the one a MUSH's parser acts on in a command argument — a substitution (<c>%</c>), a
/// function or attribute evaluation (<c>[</c>, <c>]</c>), a command separator (<c>;</c>), and the
/// braces that group an argument (<c>{</c>, <c>}</c>). <c>\</c> is in it because it is the escape
/// itself: left alone, a backslash the writer typed would eat the character after it.
/// </para>
/// </summary>
private static readonly char[] Specials = ['\\', '%', '[', ']', '{', '}', ';'];

/// <summary>
/// The one line to send for <paramref name="body"/>, or null when there is nothing to send.
/// <para>
/// 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.
/// </para>
/// </summary>
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();
}

/// <summary>
/// Splits a buffer into lines, treating CRLF, LF and a lone CR alike. The editor writes
/// <see cref="Environment.NewLine"/>, a paste carries whatever the source had, and a body that
/// travelled through either must break in the same places.
/// </summary>
private static List<string> Split(string body)
{
var lines = new List<string>();
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;
}

/// <summary>
/// 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
/// <c>%r%r</c>, which is what the writer typed and what the game will render.
/// </summary>
private static void Trim(List<string> 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);
}
}

/// <summary>Escapes one line's MUSH metacharacters. See <see cref="Specials"/> for the set.</summary>
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();
}
}

/// <summary>
/// What the composer does with the MUSH metacharacters in a body — the window's own toggle.
/// <para>
/// Two modes rather than one because both are right for different posts and neither is right for both.
/// A prose post full of <c>100%</c> and <c>[brackets]</c> wants them to arrive as themselves; a post
/// that deliberately carries <c>ansi()</c>, a <c>%r</c> 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.
/// </para>
/// </summary>
public enum ComposeEscaping
{
/// <summary>
/// Send what was typed. Only the line breaks become <c>%r</c>; every other character reaches the
/// game as itself and the game does what it does with it.
/// </summary>
AsTyped,

/// <summary>
/// Protect the body, so the post shows the characters that were typed. See
/// <see cref="ComposeMessage"/> for the set and why the escaping precedes the join.
/// </summary>
Literal,
}
Loading
Loading