Skip to content

Commit 588666d

Browse files
committed
feat: terminal-level transparency via Color.Transparent (ANSI 49m)
When a cell's background has A=0, the ANSI formatter now emits \x1b[49m (terminal default background) instead of explicit RGB. This allows terminal emulators with native transparency (Kitty, Alacritty, WezTerm) to show the desktop wallpaper through SharpConsoleUI. Set theme.DesktopBackgroundColor = Color.Transparent to enable. Adds TerminalTransparencyMode option to control how semi-transparent windows behave over transparent desktop (PreserveWindowColor default vs PreserveTerminalTransparency). Also: fix DesktopBackgroundService.Render for A=0 bg, fix PxToCharRatio back to 8.0, schost RunCommand net10.0 + Linux exe discovery.
1 parent 3620634 commit 588666d

9 files changed

Lines changed: 344 additions & 19 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using SharpConsoleUI;
2+
using SharpConsoleUI.Configuration;
3+
using SharpConsoleUI.Tests.Infrastructure;
4+
using Xunit;
5+
6+
namespace SharpConsoleUI.Tests.Rendering.Unit.BottomLayer;
7+
8+
public class TerminalTransparencyTests
9+
{
10+
[Fact]
11+
public void TransparentDesktop_TransparentWindow_CellEmits49()
12+
{
13+
// Full transparency stack: both desktop and window transparent
14+
// Cells should emit 49 (terminal default bg), not 48;2;R;G;B
15+
var system = TestWindowSystemBuilder.CreateTestSystem();
16+
((SharpConsoleUI.Themes.ModernGrayTheme)system.Theme).DesktopBackgroundColor = Color.Transparent;
17+
system.DesktopBackgroundService.NeedsScreenUpdate = true;
18+
system.Render.DesktopNeedsRender = true;
19+
system.Render.UpdateDisplay();
20+
21+
var window = new Window(system)
22+
{
23+
Left = 5, Top = 3, Width = 15, Height = 6,
24+
Title = "T",
25+
BackgroundColor = Color.Transparent
26+
};
27+
system.WindowStateService.AddWindow(window);
28+
system.Render.UpdateDisplay();
29+
30+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
31+
Assert.NotNull(snapshot);
32+
33+
var cell = snapshot.GetBack(6, 4);
34+
Assert.Contains("49", cell.AnsiEscape);
35+
Assert.DoesNotContain("48;2;", cell.AnsiEscape);
36+
}
37+
38+
[Fact]
39+
public void TransparentDesktop_OpaqueWindow_CellEmitsRGB()
40+
{
41+
// Transparent desktop but opaque window — window RGB should win
42+
var system = TestWindowSystemBuilder.CreateTestSystem();
43+
((SharpConsoleUI.Themes.ModernGrayTheme)system.Theme).DesktopBackgroundColor = Color.Transparent;
44+
system.DesktopBackgroundService.NeedsScreenUpdate = true;
45+
system.Render.DesktopNeedsRender = true;
46+
system.Render.UpdateDisplay();
47+
48+
var window = new Window(system)
49+
{
50+
Left = 5, Top = 3, Width = 15, Height = 6,
51+
Title = "T",
52+
BackgroundColor = new Color(0, 0, 128)
53+
};
54+
system.WindowStateService.AddWindow(window);
55+
system.Render.UpdateDisplay();
56+
57+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
58+
Assert.NotNull(snapshot);
59+
60+
var cell = snapshot.GetBack(6, 4);
61+
Assert.Contains("48;2;0;0;128", cell.AnsiEscape);
62+
}
63+
64+
[Fact]
65+
public void TransparentDesktop_ExposedArea_CellEmits49()
66+
{
67+
// Desktop area not covered by any window should emit 49
68+
var system = TestWindowSystemBuilder.CreateTestSystem();
69+
((SharpConsoleUI.Themes.ModernGrayTheme)system.Theme).DesktopBackgroundColor = Color.Transparent;
70+
system.DesktopBackgroundService.NeedsScreenUpdate = true;
71+
system.Render.DesktopNeedsRender = true;
72+
system.Render.UpdateDisplay();
73+
74+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
75+
Assert.NotNull(snapshot);
76+
77+
// Cell in the desktop area (no window)
78+
var cell = snapshot.GetBack(0, 0);
79+
Assert.Contains("49", cell.AnsiEscape);
80+
Assert.DoesNotContain("48;2;", cell.AnsiEscape);
81+
}
82+
83+
[Fact]
84+
public void OpaqueDesktop_OpaqueWindow_CellEmitsRGB()
85+
{
86+
// Normal case: opaque desktop + opaque window — always explicit RGB
87+
var system = TestWindowSystemBuilder.CreateTestSystem();
88+
89+
var window = new Window(system)
90+
{
91+
Left = 5, Top = 3, Width = 15, Height = 6,
92+
Title = "T",
93+
BackgroundColor = new Color(100, 50, 200)
94+
};
95+
system.WindowStateService.AddWindow(window);
96+
system.Render.UpdateDisplay();
97+
98+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
99+
Assert.NotNull(snapshot);
100+
101+
var cell = snapshot.GetBack(6, 4);
102+
Assert.Contains("48;2;100;50;200", cell.AnsiEscape);
103+
}
104+
105+
[Fact]
106+
public void OpaqueDesktop_TransparentWindow_BlendsToDesktopColor()
107+
{
108+
// Transparent window over opaque desktop — compositor resolves to desktop color
109+
// Cell should emit explicit RGB (the desktop's color), NOT 49
110+
var system = TestWindowSystemBuilder.CreateTestSystem();
111+
112+
var window = new Window(system)
113+
{
114+
Left = 5, Top = 3, Width = 15, Height = 6,
115+
Title = "T",
116+
BackgroundColor = Color.Transparent
117+
};
118+
system.WindowStateService.AddWindow(window);
119+
system.Render.UpdateDisplay();
120+
121+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
122+
Assert.NotNull(snapshot);
123+
124+
var cell = snapshot.GetBack(6, 4);
125+
// Should be the theme's desktop color (opaque), not 49
126+
Assert.Contains("48;2;", cell.AnsiEscape);
127+
}
128+
129+
[Fact]
130+
public void PreserveTerminalTransparency_SemiTransparentWindow_Emits49()
131+
{
132+
var system = TestWindowSystemBuilder.CreateTestSystem(opts =>
133+
opts with { TerminalTransparencyMode = TerminalTransparencyMode.PreserveTerminalTransparency });
134+
135+
((SharpConsoleUI.Themes.ModernGrayTheme)system.Theme).DesktopBackgroundColor = Color.Transparent;
136+
system.DesktopBackgroundService.NeedsScreenUpdate = true;
137+
system.Render.DesktopNeedsRender = true;
138+
system.Render.UpdateDisplay();
139+
140+
var window = new Window(system)
141+
{
142+
Left = 5, Top = 3, Width = 15, Height = 6,
143+
Title = "T",
144+
BackgroundColor = new Color(0, 50, 100, 128)
145+
};
146+
system.WindowStateService.AddWindow(window);
147+
system.Render.UpdateDisplay();
148+
149+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
150+
Assert.NotNull(snapshot);
151+
152+
var cell = snapshot.GetBack(6, 4);
153+
Assert.Contains("49", cell.AnsiEscape);
154+
Assert.DoesNotContain("48;2;", cell.AnsiEscape);
155+
}
156+
157+
[Fact]
158+
public void PreserveWindowColor_SemiTransparentWindow_EmitsRGB()
159+
{
160+
// Default mode: semi-transparent window blends against black, emits RGB
161+
var system = TestWindowSystemBuilder.CreateTestSystem();
162+
163+
((SharpConsoleUI.Themes.ModernGrayTheme)system.Theme).DesktopBackgroundColor = Color.Transparent;
164+
system.DesktopBackgroundService.NeedsScreenUpdate = true;
165+
system.Render.DesktopNeedsRender = true;
166+
system.Render.UpdateDisplay();
167+
168+
var window = new Window(system)
169+
{
170+
Left = 5, Top = 3, Width = 15, Height = 6,
171+
Title = "T",
172+
BackgroundColor = new Color(0, 50, 100, 128)
173+
};
174+
system.WindowStateService.AddWindow(window);
175+
system.Render.UpdateDisplay();
176+
177+
var snapshot = system.RenderingDiagnostics?.LastConsoleSnapshot;
178+
Assert.NotNull(snapshot);
179+
180+
var cell = snapshot.GetBack(6, 4);
181+
Assert.Contains("48;2;", cell.AnsiEscape);
182+
}
183+
}

SharpConsoleUI/Configuration/ConsoleWindowSystemOptions.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ public enum DirtyTrackingMode
5252
Smart = 2
5353
}
5454

55+
/// <summary>
56+
/// Controls how semi-transparent windows behave when the desktop background is transparent (A=0).
57+
/// </summary>
58+
public enum TerminalTransparencyMode
59+
{
60+
/// <summary>
61+
/// Semi-transparent window colors blend against black (the RGB of Color.Transparent).
62+
/// The window shows a dark tinted color. Terminal transparency is lost under the window.
63+
/// This is the default — predictable, color-preserving behavior.
64+
/// </summary>
65+
PreserveWindowColor,
66+
67+
/// <summary>
68+
/// Semi-transparent windows over a transparent desktop emit ANSI 49 (terminal default bg).
69+
/// The window's tint color is lost, but terminal-level transparency shows through.
70+
/// Use this when terminal transparency is more important than window tinting.
71+
/// </summary>
72+
PreserveTerminalTransparency
73+
}
74+
5575
/// <summary>
5676
/// Configuration options for ConsoleWindowSystem behavior.
5777
/// </summary>
@@ -91,7 +111,10 @@ public record ConsoleWindowSystemOptions(
91111
bool ShowBottomPanel = true,
92112

93113
// Desktop background configuration (gradient, pattern, animated). Null uses theme defaults.
94-
DesktopBackgroundConfig? DesktopBackground = null
114+
DesktopBackgroundConfig? DesktopBackground = null,
115+
116+
// Terminal transparency behavior for semi-transparent windows over transparent desktop
117+
TerminalTransparencyMode TerminalTransparencyMode = TerminalTransparencyMode.PreserveWindowColor
95118
)
96119
{
97120
private const string PerfMetricsEnvVar = "SHARPCONSOLEUI_PERF_METRICS";

SharpConsoleUI/Core/DesktopBackgroundService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ public void Render(int width, int height)
9999
var bgChar = theme.DesktopBackgroundChar;
100100
var bgColor = theme.DesktopBackgroundColor;
101101
var fgColor = theme.DesktopForegroundColor;
102-
_buffer.FillRect(new LayoutRect(0, 0, width, height), bgChar, fgColor, bgColor);
102+
// Use Clear for transparent bg — FillRect uses Color.Blend which is a no-op for A=0
103+
if (bgColor.A == 0)
104+
_buffer.Clear(bgColor);
105+
else
106+
_buffer.FillRect(new LayoutRect(0, 0, width, height), bgChar, fgColor, bgColor);
103107

104108
// If PaintCallback is set, it takes full control
105109
if (_config?.PaintCallback != null)

SharpConsoleUI/Drivers/ConsoleBuffer.cs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,35 @@ private string FormatCellAnsi(Color fg, Color bg, TextDecoration decorations = T
108108
var sb = _formatBuilder;
109109
sb.Clear();
110110
// Always reset first so decorations from previous cells don't leak
111-
sb.Append("\x1b[0;38;2;");
112-
sb.Append(fg.R); sb.Append(';');
113-
sb.Append(fg.G); sb.Append(';');
114-
sb.Append(fg.B); sb.Append(";48;2;");
115-
sb.Append(bg.R); sb.Append(';');
116-
sb.Append(bg.G); sb.Append(';');
117-
sb.Append(bg.B);
111+
sb.Append("\x1b[0");
112+
113+
// Foreground: A=0 means use terminal default (39), otherwise explicit RGB
114+
if (fg.A == 0)
115+
{
116+
sb.Append(";39");
117+
}
118+
else
119+
{
120+
sb.Append(";38;2;");
121+
sb.Append(fg.R); sb.Append(';');
122+
sb.Append(fg.G); sb.Append(';');
123+
sb.Append(fg.B);
124+
}
125+
126+
// Background: A=0 means use terminal default (49), otherwise explicit RGB.
127+
// In PreserveTerminalTransparency mode, any non-opaque bg also emits 49.
128+
if (bg.A == 0 ||
129+
(_options.TerminalTransparencyMode == Configuration.TerminalTransparencyMode.PreserveTerminalTransparency && bg.A < 255))
130+
{
131+
sb.Append(";49");
132+
}
133+
else
134+
{
135+
sb.Append(";48;2;");
136+
sb.Append(bg.R); sb.Append(';');
137+
sb.Append(bg.G); sb.Append(';');
138+
sb.Append(bg.B);
139+
}
118140

119141
if (decorations != TextDecoration.None)
120142
{

SharpConsoleUI/Drivers/NetConsoleDriver.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,9 @@ public void SetNarrowCell(int x, int y, char character, Color fg, Color bg)
593593
switch (RenderMode)
594594
{
595595
case RenderMode.Direct:
596-
var ansi = $"\x1b[38;2;{fg.R};{fg.G};{fg.B};48;2;{bg.R};{bg.G};{bg.B}m{character}\x1b[0m";
596+
var fgAnsi = fg.A == 0 ? "39" : $"38;2;{fg.R};{fg.G};{fg.B}";
597+
var bgAnsi = bg.A == 0 ? "49" : $"48;2;{bg.R};{bg.G};{bg.B}";
598+
var ansi = $"\x1b[{fgAnsi};{bgAnsi}m{character}\x1b[0m";
597599
if (_useDirectAnsi)
598600
{
599601
WriteOutput($"\x1b[{y + 1};{x + 1}H");
@@ -619,7 +621,9 @@ public void FillCells(int x, int y, int width, char character, Color fg, Color b
619621
{
620622
case RenderMode.Direct:
621623
var sb = new StringBuilder();
622-
sb.Append($"\x1b[38;2;{fg.R};{fg.G};{fg.B};48;2;{bg.R};{bg.G};{bg.B}m");
624+
var fgAnsi = fg.A == 0 ? "39" : $"38;2;{fg.R};{fg.G};{fg.B}";
625+
var bgAnsi = bg.A == 0 ? "49" : $"48;2;{bg.R};{bg.G};{bg.B}";
626+
sb.Append($"\x1b[{fgAnsi};{bgAnsi}m");
623627
sb.Append(character, width);
624628
sb.Append("\x1b[0m");
625629
if (_useDirectAnsi)
@@ -651,7 +655,9 @@ public void WriteBufferRegion(int destX, int destY, Layout.CharacterBuffer sourc
651655
for (int i = 0; i < width; i++)
652656
{
653657
var cell = source.GetCell(srcX + i, srcY);
654-
sb.Append($"\x1b[38;2;{cell.Foreground.R};{cell.Foreground.G};{cell.Foreground.B};48;2;{cell.Background.R};{cell.Background.G};{cell.Background.B}");
658+
var cfgAnsi = cell.Foreground.A == 0 ? "39" : $"38;2;{cell.Foreground.R};{cell.Foreground.G};{cell.Foreground.B}";
659+
var cbgAnsi = cell.Background.A == 0 ? "49" : $"48;2;{cell.Background.R};{cell.Background.G};{cell.Background.B}";
660+
sb.Append($"\x1b[{cfgAnsi};{cbgAnsi}");
655661
if ((cell.Decorations & TextDecoration.Bold) != 0) sb.Append(";1");
656662
if ((cell.Decorations & TextDecoration.Dim) != 0) sb.Append(";2");
657663
if ((cell.Decorations & TextDecoration.Italic) != 0) sb.Append(";3");

SharpConsoleUI/Html/HtmlConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public static class HtmlConstants
5959
public const string DefaultLoadingText = "Loading...";
6060

6161
// Unit conversion ratios
62-
public const double PxToCharRatio = 1.0;
62+
public const double PxToCharRatio = 8.0;
6363
public const double EmToCharRatio = 2.0;
6464

6565
// Image alt text

SharpConsoleUI/Renderer.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,21 @@ private void RenderVisibleWindowContentComposited(Window window, CharacterBuffer
552552
var effectiveBgBelow = IsBlockCharacter(cellBelow.Character)
553553
? cellBelow.Foreground : cellBelow.Background;
554554

555+
// PreserveTerminalTransparency: if the bottom of the stack is
556+
// terminal-transparent (A=0), skip compositing and pass A=0 through
557+
// so FormatCellAnsi emits \x1b[49m.
558+
if (effectiveBgBelow.A == 0 &&
559+
_consoleWindowSystem.Options.TerminalTransparencyMode ==
560+
Configuration.TerminalTransparencyMode.PreserveTerminalTransparency)
561+
{
562+
var passthrough = new Cell(cell.Character, cell.Foreground,
563+
Color.Transparent, cell.Decorations);
564+
passthrough.IsWideContinuation = cell.IsWideContinuation;
565+
passthrough.Combiners = cell.Combiners;
566+
_scratchBuffer.SetCellDirect(i, 0, passthrough);
567+
continue;
568+
}
569+
555570
Cell composited;
556571
if (brush != null)
557572
{

0 commit comments

Comments
 (0)