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
5 changes: 4 additions & 1 deletion src/SharpMUTerm.Core/Automation/Trigger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public sealed class TriggerActions

/// <summary>
/// Replace the whole line's text with this template (supports <c>$1</c>..<c>$9</c> and
/// <c>${name}</c> capture references). Rewritten text renders with the default style. Null — which
/// <c>${name}</c> capture references). Rewritten text starts from the default style — a rewrite is
/// how a server's own colour is dropped as well as how its wording is changed — and then wears
/// whatever <see cref="HighlightForeground"/>, <see cref="HighlightBackground"/> and
/// <see cref="AddAttributes"/> this same rule asks for, across the whole of it. Null — which
/// is what the F2 screen writes for a blank field — means the rule rewrites nothing; settable for
/// the same reason <see cref="SpawnTarget"/> is, and with the same absence of any cached state.
/// </summary>
Expand Down
38 changes: 29 additions & 9 deletions src/SharpMUTerm.Core/Automation/TriggerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,31 @@ public TriggerResult Process(StyledLine line)
suppress = true;
}

if (actions.HighlightForeground is not null ||
actions.HighlightBackground is not null ||
actions.AddAttributes != TextAttributes.None)
// The rewrite runs *before* the highlight, and that order is the whole of a reported defect.
// It used to run after, and a rewrite replaces the line wholesale with an unstyled one — so a
// rule that both rewrote and highlighted threw its own colours, attributes and left rule away
// on the very next statement. That is not an exotic combination: it is what a channel rule
// looks like (route it, tidy it to `» $1`, colour it), and it is the shape of the demo
// configuration's own headline rule. The F2 screen badged such a rule `H` and painted its
// swatches, so the client promised a highlight that could never appear.
var rewritten = false;
if (actions.Rewrite is not null)
{
current = ApplyHighlight(current, match, actions);
current = StyledLine.FromText(match.Result(actions.Rewrite), TextStyle.Default);
rewritten = true;
}

if (actions.Rewrite is not null)
if (actions.HighlightForeground is not null ||
actions.HighlightBackground is not null ||
actions.AddAttributes != TextAttributes.None)
{
var text = match.Result(actions.Rewrite);
current = StyledLine.FromText(text, TextStyle.Default);
// A rewrite makes the match's own offsets meaningless — they described the string the
// rewrite replaced — so the highlight covers the whole of what the rule produced, which
// is the only region of the new line the rule can be said to be talking about. Without a
// rewrite it covers the match and only the match, exactly as it always has.
current = rewritten
? ApplyHighlight(current, 0, current.Length, actions)
: ApplyHighlight(current, match.Index, match.Length, actions);
}

if (!string.IsNullOrEmpty(actions.SendResponse))
Expand Down Expand Up @@ -382,9 +396,15 @@ actions.HighlightBackground is not null ||
/// </summary>
public const int MaxTargetLength = 64;

private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions)
/// <summary>
/// Recolours <paramref name="length"/> characters from <paramref name="start"/> and carries the
/// rule's colour onto the whole line so the output pane can draw its left rule. The region is a
/// parameter rather than a <see cref="Match"/> because a rewritten line has no match offsets left to
/// speak of — see the call site.
/// </summary>
private static StyledLine ApplyHighlight(StyledLine line, int start, int length, TriggerActions actions)
{
var restyled = StyledText.Restyle(line, match.Index, match.Length, style =>
var restyled = StyledText.Restyle(line, start, length, style =>
{
if (actions.HighlightForeground is not null)
{
Expand Down
112 changes: 110 additions & 2 deletions src/SharpMUTerm.Core/Workspace/Workspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,111 @@ public WorkspaceWindow OpenWindow(
return window;
}

/// <summary>
/// Routes a matched trigger's line to the window <paramref name="target"/> names, on behalf of
/// <paramref name="sessionKey"/>: <b>a window that already exists wins, and a spawn window is what
/// happens when nothing answers</b>. Counts the line as unread unless the destination is currently
/// being read, and returns it.
/// <para>
/// This is the resolver a routed line goes through, and the finding half of it is the point. A rule's
/// destination used to be <see cref="RouteSpawn"/> and nothing else, which computes a spawn id and
/// registers a new <see cref="WindowKind.Spawn"/> window when nothing answers to it — so "put this in
/// the window I already have open" was not a thing a rule could ask for however it was spelt, and a
/// route naming a window on the screen opened a second one beside it wearing the same label.
/// </para>
/// <para>
/// <b>What a target may reach is deliberately narrower than "any window with that title".</b> It is
/// this session's own windows, the windows nobody owns, and another character's <em>main</em> window
/// — one alt's channel collected into the pane you actually read. It is <em>not</em> another
/// session's spawn or auxiliary window: two characters running one capture rule get a pane each, and
/// a bare title lookup would collapse them back into one and file the second character's channel
/// under the first, which is the exact defect <see cref="SpawnWindowId(string?,string)"/> was given
/// an owner to fix. A main window is admitted across that boundary because it is a window the user
/// opened by connecting, rather than one a rule conjured out of a capture.
/// </para>
/// <para>
/// <b>Only a placed window is a destination.</b> Appending to a window no pane holds writes into a
/// buffer nothing can draw, which from the reader's side is indistinguishable from the rule not
/// firing at all; a closed window is passed over and the line goes somewhere visible.
/// </para>
/// <para>
/// <b>Finding never creates.</b> A target is often a template with capture groups in it
/// (<c>Channel $1</c>), so the name can be the server's text — and the security property that keeps
/// that bounded is that this arm can only ever land in a window the user already has. Making one out
/// of a captured name still goes through <see cref="RouteSpawn"/>, which puts the matching session's
/// own key on it.
/// </para>
/// </summary>
public WorkspaceWindow RouteLine(string target, string? sessionKey = null)
{
ArgumentException.ThrowIfNullOrEmpty(target);
if (FindRouteTarget(target, sessionKey) is not { } existing)
{
return RouteSpawn(target, sessionKey);
}

NoteActivity(existing.Id);
return existing;
}

/// <summary>
/// The window <paramref name="target"/> already names for <paramref name="sessionKey"/>, or null when
/// nothing does — the finding half of <see cref="RouteLine"/>, with no side effects, so a caller can
/// tell "this line opened a pane" from "this line went to one that was already there" without
/// routing twice. See <see cref="RouteLine"/> for what a target may and may not reach.
/// </summary>
public WorkspaceWindow? FindRouteTarget(string target, string? sessionKey = null)
{
ArgumentException.ThrowIfNullOrEmpty(target);

// Preference order, and it has to be total: several windows may carry one title, and a route that
// resolved differently from one line to the next would scatter a channel across panes. This
// session's own first, then the unowned, then another character's main; ties inside a group go to
// the older window, which is the same creation order everything else here numbers windows in.
var best = _windows.Values
.Where(w => string.Equals(w.Title, target, StringComparison.Ordinal))
.Where(w => Layout.FindWindow(w.Id) is not null)
.Select(w => (Window: w, Rank: RouteRank(w, sessionKey)))
.Where(candidate => candidate.Rank >= 0)
.OrderBy(candidate => candidate.Rank)
.ThenBy(candidate => candidate.Window.Sequence)
.Select(candidate => candidate.Window)
.FirstOrDefault();

// A spawn window the user has since renamed answers to no title, and its rule must go on feeding
// it rather than opening a second pane beside it under the old name.
//
// Placed, like the title lookup above it, and for the same reason: a window the registry still
// knows and no pane holds is *closed* (see the numbering remarks), and routing to one writes the
// channel into a buffer nobody can see. The registry outlives the layout in two ways — a restored
// workspace registers windows a saved layout no longer places — so this is reachable rather than
// theoretical. Falling through instead is not a loss: RouteLine then reaches RouteSpawn, which
// places this very window again under the same id, so the pane reopens with its history in it.
var renamed = _windows.GetValueOrDefault(SpawnWindowId(sessionKey, target));
return best ?? (renamed is not null && Layout.FindWindow(renamed.Id) is not null ? renamed : null);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// <summary>
/// How willingly <paramref name="window"/> takes a line routed by <paramref name="sessionKey"/> —
/// lower is better, and negative means never.
/// </summary>
private static int RouteRank(WorkspaceWindow window, string? sessionKey) => window switch
{
_ when window.SessionKey is not null && string.Equals(window.SessionKey, sessionKey, StringComparison.Ordinal) => 0,
_ when window.SessionKey is null => 1,
_ when window.Kind == WindowKind.Main => 2,
_ => -1,
};

/// <summary>
/// Routes trigger-spawned output to <paramref name="sessionKey"/>'s spawn window named
/// <paramref name="target"/>, creating and placing the window on first use, and counts the line as
/// unread unless the window is currently visible. Returns the destination window.
/// <para>
/// <b>This is the creating half only</b>; <see cref="RouteLine"/> is what a routed line goes through,
/// and it reaches here when no window the target names already exists.
/// </para>
/// <para>
/// <b>The destination is per session, not per workspace.</b> Two connected characters running the
/// same capture rule each get a window of their own; the id carries the owner, so the second
/// session to match cannot land in the first's window. It used to: the id was the target alone, so
Expand All @@ -173,9 +273,17 @@ public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null)
{
ArgumentException.ThrowIfNullOrEmpty(target);
var id = SpawnWindowId(sessionKey, target);
if (!_windows.TryGetValue(id, out var window))
var window = _windows.TryGetValue(id, out var existing)
? existing
: Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey));

// Placed on the way past, and *not* only when the window is new. The registry outlives the layout
// — a restored workspace registers windows a saved layout no longer places — so a window can be
// known and closed at once, and returning that from a route writes the channel into a buffer
// nobody can see. Making this total is what lets FindRouteTarget decline a closed window and fall
// through here: the pane reopens under the same id, with its history already in it.
if (Layout.FindWindow(id) is null)
{
window = Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey));
Layout.AddWindow(id, activate: false); // spawns open in the background and accrue unread
}

Expand Down
Loading
Loading