From 92fe581a6b11b29cefcbef0f783e748c02825384 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:00:44 +0000 Subject: [PATCH] Keep a box's edges beside a picture drawn under a line inside it A figure the server could not size in time arrives as one row of description inside its box. Its picture went under that row with no edges, cutting the box open. The picture now carries the line's upright edges down either side and is held to the blank columns between them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW --- CLAUDE.md | 3 + src/SharpMUTerm.Tui/SharpMUTermApp.cs | 84 ++++++++++++++++++-- tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs | 32 ++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e0e8f4f3..5ea000a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -455,6 +455,9 @@ fallbacks) for inline images/maps. (`PlaceInlinePicture`, `Linked`); a line that was only the picture becomes the picture's first row. This happens when the picture *arrives*, never at append time, because a picture that fails keeps its label as the only thing standing for it. `PaneLine.Plain` keeps the label, so ⌃F still finds a picture by name. + **A picture under a line inside a box carries the box's edges down either side of it** (`BoxEdgesAround`) + and is held to the blank columns between them. That is a figure the server could not size in time, sent as + one row of description; without the edges its rows cut the box open for as many rows as it is tall. Decisions not to relitigate: - **A picture is pane rows, never a control.** Kitty placeholders (`U+10EEEE` + row/column diacritics, the image id in the foreground) and half-blocks are both plain markup a `MarkupControl` already draws, so a diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index ef25f9e1..5eb7c04e 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -3434,11 +3434,12 @@ private void AppendSessionLine(string windowId, StyledLine line, string stamp) IGraphicsProtocol? Kitty() => _system.ConsoleDriver as IGraphicsProtocol; if (anchor != 0) { - var shown = new InlinePictureLine(line, inline); + var edges = inline.Count == 1 ? BoxEdgesAround(line, inline[0]) : null; + var shown = new InlinePictureLine(line, inline, edges); _inlineImages.Load( inline.Select(index => line.Spans[index].Interaction!.Image!).ToArray(), presentation, - InlineImageColumns(windowId), + Math.Min(InlineImageColumns(windowId), edges?.Columns ?? int.MaxValue), Kitty, (index, rows) => PlaceInlinePicture(windowId, anchor, shown, index, rows)); } @@ -3487,6 +3488,11 @@ private void PlaceInlinePicture(string windowId, long anchor, InlinePictureLine var span = shown.Labels[index]; rows = Linked(rows, shown.Line.Spans[span].Interaction); + if (shown.Edges is { } edges) + { + rows = rows.Select(row => + edges.Before + row + new string(' ', Math.Max(0, edges.Columns - PictureSlots.Cells(row))) + edges.After).ToArray(); + } // A line that also has cells a server left for a picture is held in pieces around them // (PaneLine.Pictures), and rebuilding it from its spans would undo what was drawn there. It keeps @@ -3504,14 +3510,17 @@ private void PlaceInlinePicture(string windowId, long anchor, InlinePictureLine shown.Line.RuleColor, shown.Line.IsPrompt); + // A line inside a box is empty once its label goes when all that is left is the box's edges. + var empty = string.IsNullOrWhiteSpace(shown.Edges is null ? rest.Text : WithoutEdges(rest.Text)); + // Plain keeps the text the server sent, as a reserved picture's line does, so ⌃F still finds a // picture by its name. - if (first && string.IsNullOrWhiteSpace(rest.Text)) + if (first && empty) { buffer[at] = buffer[at] with { Markup = rows[0] }; InsertImageRows(windowId, anchor, rows.Skip(1).ToArray(), repaint: true); } - else if (string.IsNullOrWhiteSpace(rest.Text) && at + 1 < buffer.Count && buffer[at + 1].ImageAnchor == -anchor) + else if (empty && at + 1 < buffer.Count && buffer[at + 1].ImageAnchor == -anchor) { // The last label of a line holding only pictures, with the earlier ones already under it. This // picture cannot take the line, or it would sit above them; the first row under the line moves @@ -3527,11 +3536,76 @@ private void PlaceInlinePicture(string windowId, long anchor, InlinePictureLine } } + /// + /// The edges of the box a picture's label sits in, when it sits in one: the line's text either side of + /// the label with everything but the box's upright edges blanked, and the blank columns the server left + /// after the label. A picture that went under such a line without them would cut the box open for as + /// many rows as the picture is tall; with them the box grows around the picture, and the picture is held + /// to the room inside it. + /// + /// This is the picture the server could not size in time, so it laid out one row of description rather + /// than the picture's cells ( is the case where it could). + /// + /// + private BoxEdges? BoxEdgesAround(StyledLine line, int label) + { + var start = line.Spans.Take(label).Sum(span => span.Text.Length); + var end = start + line.Spans[label].Text.Length; + var text = line.Text; + var blanks = 0; + while (end + blanks < text.Length && text[end + blanks] == ' ') + { + blanks++; + } + + if (blanks == 0 || !text[..start].Any(IsUprightEdge) || !text[(end + blanks)..].Any(IsUprightEdge)) + { + return null; + } + + return new BoxEdges( + _formatter.ToMarkup(EdgesOf(line, 0, start)), + _formatter.ToMarkup(EdgesOf(line, end + blanks, text.Length)), + Math.Min(blanks, KittyGraphicsProtocol.RowColumnDiacritics.Length)); + } + + /// The characters a box's left and right sides are drawn in. + private static bool IsUprightEdge(char c) => c is '|' or '│' or '┃' or '║' or '╎' or '╏' or '┆' or '┇' or '┊' or '┋' or '▏' or '▕'; + + private static string WithoutEdges(string text) => new(text.Select(c => IsUprightEdge(c) ? ' ' : c).ToArray()); + + /// The part of from to , keeping its styles and upright edges and blanking every other character. + private static StyledLine EdgesOf(StyledLine line, int from, int to) + { + var spans = new List(); + var at = 0; + foreach (var span in line.Spans) + { + var a = Math.Max(from, at); + var b = Math.Min(to, at + span.Text.Length); + if (a < b) + { + var part = span.Text[(a - at)..(b - at)]; + spans.Add(new StyledSpan(new string(part.Select(c => IsUprightEdge(c) ? c : ' ').ToArray()), span.Style)); + } + + at += span.Text.Length; + } + + return new StyledLine(spans); + } + + /// A box's edges either side of a picture, as markup, and the columns between them. + private sealed record BoxEdges(string Before, string After, int Columns); + /// A line whose inline pictures are on their way: which of its spans are their labels, and which have arrived. - private sealed class InlinePictureLine(StyledLine line, IReadOnlyList labels) + private sealed class InlinePictureLine(StyledLine line, IReadOnlyList labels, BoxEdges? edges) { public StyledLine Line { get; } = line; + /// The box the line's one picture sits in, when it sits in one. + public BoxEdges? Edges { get; } = edges; + /// The span holding each picture's label, in the order the pictures were asked for. public IReadOnlyList Labels { get; } = labels; diff --git a/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs b/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs index 24f3cf49..fc0eeccf 100644 --- a/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs @@ -195,6 +195,38 @@ public async Task TwoPicturesOnALineOfTheirOwnLeaveNoBlankRow() await Assert.That(lines[at + 1 + expected.Length]).Contains("After."); } + /// + /// A picture in a box that the server could not size in time comes as one row of description inside the + /// box. Drawn under that row it would cut the box open; it takes the row's place instead, the box's edges + /// go down either side of it, and it is held to the room between them. + /// + [Test] + public async Task APictureInABoxKeepsTheBoxsEdges() + { + await using var run = await Start(GraphicsProtocol.HalfBlock); + run.App.ImageFetch = (_, _) => Task.FromResult(Png(320, 160)); + var before = run.App.PaneLines(MainWindow).Count; + + run.Receive("┌" + new string('─', 18) + "┐"); + run.Receive(Secure + "│ " + new string(' ', 16) + " │"); + run.Receive("│ Nothing to see. │"); + run.Receive("└" + new string('─', 18) + "┘"); + await run.App.InlineImagesSettled; + run.App.RenderNextFrame(); + + var lines = run.App.PaneLines(MainWindow).Skip(before).Select(MarkupText.Plain).ToList(); + await Assert.That(lines.Select(l => l.Length).Distinct().ToArray()).IsEquivalentTo(new[] { 20 }); + var picture = lines.Skip(1).TakeWhile(l => l.Contains('▀')).ToList(); + await Assert.That(picture.Count).IsGreaterThan(1); + foreach (var row in picture) + { + await Assert.That(row).StartsWith("│ ").And.EndsWith("│"); + } + + await Assert.That(lines[1 + picture.Count]).IsEqualTo("│ Nothing to see. │"); + await Assert.That(lines.Any(l => l.Contains("[image:", StringComparison.Ordinal))).IsFalse(); + } + /// A picture inside a <SEND> sends that command when clicked, as its label did. [Test] public async Task APictureInsideASendIsThatCommand()