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()