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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ What follows is how TuiCode implements it today.
## About dialog (#107)

- `tui` opens `AboutView`. If the terminal supports sixel (WezTerm, iTerm2, tmux 3.6, Windows Terminal, foot…) it shows the artwork as an image. Otherwise (kitty, Ghostty, Terminal.app — TG 2.1.0 has no kitty graphics) it shows ASCII art.
- `SixelProbe` runs once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. We send DA1, then iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and take the first answer. It also parses tmux's DA1 reply (`…;4c`), which TG's check misses.
- `SixelProbe.Detect` sends DA1 once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. `SixelProbe.MeasureCell` runs each time About opens, because the cell's pixel size changes when the window moves between a Retina and a non-Retina screen. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. `MeasureCell` sends iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and takes the first answer. `Detect` also parses tmux's DA1 reply (`…;4c`), which TG's check misses.
- iTerm2 reports sizes in points everywhere, its `CSI 14 t` reply included. Only `ReportCellSize` carries the scale (`2.0` on Retina), so `ParseIterm2CellSize` multiplies by it (clamped to 1–4; absent in older iTerm2s, meaning 1). Sixels are drawn in device pixels, so ignoring it drew the About image at a quarter of its area (#333).
- The cell size has to be exact: iTerm2 blanks every row an image touches, so an image that ends mid-row leaves a dark band (`AboutImage.Fit` rounds down to whole rows and `Cover` trims the top/bottom to match).
- Encoding runs in the background and is cached per pixel size, so reopening shows the image immediately.
- TG re-emits queued sixels on every output write and only rewrites cells whose contents changed, so `AboutView.Dispose` dequeues its sixel and sets `ClearScreenNextIteration`. Otherwise the image stays on screen after close.
Expand Down
27 changes: 11 additions & 16 deletions src/TuiCode.Workbench/About/SixelProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,22 @@ internal static class SixelProbe
{
private static readonly SizeF DefaultCellPixels = new(10, 20);

/// <summary>Asks the terminal whether it draws sixel images and how many pixels a cell is.</summary>
public static void Detect(IDriver driver, Action<SixelSupport> found)
/// <summary>Asks the terminal whether it draws sixel images.</summary>
public static void Detect(IDriver driver, Action<bool> found)
{
if (driver.IsLegacyConsole)
{
found(SixelSupport.Unsupported);
found(false);
return;
}

Queue(driver, EscSeqUtils.CSI_SendDeviceAttributes,
response =>
{
if (IndicatesSixel(response))
FindCellPixels(driver, cellPixels => found(new SixelSupport(true, cellPixels)));
else
found(SixelSupport.Unsupported);
},
() => found(SixelSupport.Unsupported));
Queue(driver, EscSeqUtils.CSI_SendDeviceAttributes, response => found(IndicatesSixel(response)), () => found(false));
}

// iTerm2 answers only its own query and most other terminals only CSI 16 t. An unanswered query takes TG a
// second to abandon, so ask both at once and take the first answer.
private static void FindCellPixels(IDriver driver, Action<SizeF> found)
/// <summary>Asks how many pixels a cell is.</summary>
public static void MeasureCell(IDriver driver, Action<SizeF> found)
{
var settled = false;
var misses = 0;
Expand Down Expand Up @@ -90,11 +83,13 @@ internal static bool IndicatesSixel(string? response) =>
return match.Success ? Positive(match.Groups[2].Value, match.Groups[1].Value) : null;
}

/// <summary>Reads iTerm2's <c>OSC 1337 ; ReportCellSize=height;width;scale ST</c>.</summary>
/// <summary>Reads iTerm2's <c>OSC 1337 ; ReportCellSize=height;width;scale ST</c>, in points times the scale.</summary>
internal static SizeF? ParseIterm2CellSize(string? response)
{
var match = Regex.Match(response ?? "", @"ReportCellSize=([\d.]+);([\d.]+)");
return match.Success ? Positive(match.Groups[2].Value, match.Groups[1].Value) : null;
var match = Regex.Match(response ?? "", @"ReportCellSize=([\d.]+);([\d.]+)(?:;([\d.]+))?");
if (!match.Success || Positive(match.Groups[2].Value, match.Groups[1].Value) is not { } points) return null;
var scale = float.TryParse(match.Groups[3].Value, CultureInfo.InvariantCulture, out var s) ? Math.Clamp(s, 1, 4) : 1;
return scale == 1 ? points : new SizeF(MathF.Round(points.Width * scale), MathF.Round(points.Height * scale));
}

private static SizeF? Positive(string width, string height) =>
Expand Down
26 changes: 21 additions & 5 deletions src/TuiCode.Workbench/WorkbenchHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public sealed class WorkbenchHost : IDisposable
private AboutView? _activeAbout;
private DocumentInfoView? _activeDocumentInfo;
private BlameView? _activeBlame;
private SixelSupport? _sixelSupport;
private bool? _sixelSupported;
private MnemonicView? _activeMnemonics;
private OpenView? _activeOpen;
private PathPromptView? _activePathPrompt;
Expand Down Expand Up @@ -126,10 +126,10 @@ public WorkbenchHost(
_terminalCursors.Detect();
// Detected up front so About can show a spinner rather than ASCII art that the image then replaces.
if (_app.Driver is { } driver)
SixelProbe.Detect(driver, support => _app.Invoke(() =>
SixelProbe.Detect(driver, supported => _app.Invoke(() =>
{
_sixelSupport = support;
_activeAbout?.Present(support);
_sixelSupported = supported;
if (_activeAbout is { } about) PresentAbout(about);
}));
_workbench = workbench;
_commands = commands;
Expand Down Expand Up @@ -1461,7 +1461,23 @@ private void OpenAbout()
_scopes.Push(view.Scope);
view.SetFocus();

view.Present(_sixelSupport);
PresentAbout(view);
}

private void PresentAbout(AboutView view)
{
if (_sixelSupported is not true || _app.Driver is not { } driver)
{
view.Present(_sixelSupported is null ? null : SixelSupport.Unsupported);
return;
}

view.Present(null);
// Measured on every open: the window may have moved to a screen with another scale since the last one.
SixelProbe.MeasureCell(driver, cellPixels => _app.Invoke(() =>
{
if (ReferenceEquals(_activeAbout, view)) view.Present(new SixelSupport(true, cellPixels));
}));
}

private void CloseAbout(AboutView view)
Expand Down
22 changes: 15 additions & 7 deletions tests/TuiCode.Tests/AboutViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,26 @@ public void Cover_trims_the_top_and_bottom_to_keep_the_aspect_ratio()

public class SixelProbeTests
{
[Fact]
public void ParseIterm2CellSize_reads_width_and_height()
{
var size = SixelProbe.ParseIterm2CellSize("\x1b]1337;ReportCellSize=26.5;11.0;2.0\x1b\\");

Assert.Equal(new System.Drawing.SizeF(11f, 26.5f), size);
}
[Theory]
[InlineData("17.50;8.00;2.0", 16, 35)]
[InlineData("26.0;11.0;2.0", 22, 52)]
[InlineData("26.5;11.0;1.0", 11, 26.5)]
[InlineData("26.5;11.0", 11, 26.5)]
[InlineData("17.5;8.0;1.5", 12, 26)]
[InlineData("20;10;400", 40, 80)]
[InlineData("20;10;0", 10, 20)]
[InlineData("20;10;1.2.3", 10, 20)]
public void ParseIterm2CellSize_reads_the_cell_in_device_pixels(string reply, float width, float height) =>
Assert.Equal(
new System.Drawing.SizeF(width, height),
SixelProbe.ParseIterm2CellSize($"\x1b]1337;ReportCellSize={reply}\x1b\\"));

[Theory]
[InlineData(null)]
[InlineData("\x1b[?64;1;2;4c")]
[InlineData("\x1b]1337;ReportCellSize=0;11\x1b\\")]
[InlineData("\x1b]1337;ReportCellSize=0;11;2.0\x1b\\")]
[InlineData("\x1b]1337;ReportCellSize=1.2.3;11;2.0\x1b\\")]
public void ParseIterm2CellSize_rejects_other_replies(string? response) =>
Assert.Null(SixelProbe.ParseIterm2CellSize(response));

Expand Down
Loading