diff --git a/AGENTS.md b/AGENTS.md
index d9a49fd..c32b1e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/src/TuiCode.Workbench/About/SixelProbe.cs b/src/TuiCode.Workbench/About/SixelProbe.cs
index 26b1020..4c9d85d 100644
--- a/src/TuiCode.Workbench/About/SixelProbe.cs
+++ b/src/TuiCode.Workbench/About/SixelProbe.cs
@@ -14,29 +14,22 @@ internal static class SixelProbe
{
private static readonly SizeF DefaultCellPixels = new(10, 20);
- /// Asks the terminal whether it draws sixel images and how many pixels a cell is.
- public static void Detect(IDriver driver, Action found)
+ /// Asks the terminal whether it draws sixel images.
+ public static void Detect(IDriver driver, Action 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 found)
+ /// Asks how many pixels a cell is.
+ public static void MeasureCell(IDriver driver, Action found)
{
var settled = false;
var misses = 0;
@@ -90,11 +83,13 @@ internal static bool IndicatesSixel(string? response) =>
return match.Success ? Positive(match.Groups[2].Value, match.Groups[1].Value) : null;
}
- /// Reads iTerm2's OSC 1337 ; ReportCellSize=height;width;scale ST.
+ /// Reads iTerm2's OSC 1337 ; ReportCellSize=height;width;scale ST, in points times the scale.
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) =>
diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs
index 9b36551..f50267c 100644
--- a/src/TuiCode.Workbench/WorkbenchHost.cs
+++ b/src/TuiCode.Workbench/WorkbenchHost.cs
@@ -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;
@@ -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;
@@ -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)
diff --git a/tests/TuiCode.Tests/AboutViewTests.cs b/tests/TuiCode.Tests/AboutViewTests.cs
index 6bd46be..aad81b0 100644
--- a/tests/TuiCode.Tests/AboutViewTests.cs
+++ b/tests/TuiCode.Tests/AboutViewTests.cs
@@ -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));