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