From 577186acbd8a04f38d57576cc650276c2e954566 Mon Sep 17 00:00:00 2001 From: John Campion Jr Date: Sun, 23 Aug 2026 21:29:07 -0400 Subject: [PATCH] Implement the colour palette behind OSC 4 and OSC 10/11/12 All three handlers were stubs. OSC 4 parsed its arguments and hit a TODO, the resets did nothing at all, and colour QUERIES answered with constants: "10" => rgb:ff/ff/ff "11" => rgb:00/00/00 "12" => rgb:ff/ff/ff The query is the one that does damage. Programs ask OSC 11 for the background to decide whether they are on a light or a dark terminal, and a hardcoded black told every one of them "dark" -- so a light terminal got dark-theme colours drawn onto it, and nothing in the exchange looked like an error. ColorPalette holds 256 indexed colours plus foreground, background and cursor, seeded from TerminalOptions.Theme, which existed and was read by nothing. That is how an embedder declares a light terminal; ApplyTheme re-seeds at runtime for one that follows the OS setting. Defaults and current values are separate layers, and reset restores the EMBEDDER'S theme rather than a factory palette. Otherwise any program sending OSC 104 drags a light terminal to black and leaves it there -- a reset that un-configures the terminal is worse than one that does nothing. Where no theme is set the defaults reproduce the previous hardcoded answers, so this moves no colours for anyone; it only stops the answers being fiction. Details that are easy to get wrong and are tested: - rgb: channels are 1 to 4 hex digits and scale by WIDTH, so rgb:f/0/0 is full red rather than 0x0f0000. - Query replies use four digits per channel, as xterm emits and as probing programs are written to read, widening by repetition so 0xff becomes 0xffff. - OSC 4 takes any number of index/spec pairs; theme scripts send all sixteen ANSI colours in one sequence. - OSC 10 with several specs walks on to 11 and 12, so OSC 10;fg;bg sets both. - The 6x6x6 cube uses xterm's uneven levels, and the greyscale ramp deliberately excludes pure black and white. - IsLightBackground is luma-weighted, not a channel average, which would call pure blue light. Co-Authored-By: Claude Opus 5 (1M context) --- src/XTerm.NET.Tests/ColorPaletteTests.cs | 327 +++++++++++++++++++++++ src/XTerm.NET/Common/ColorPalette.cs | 301 +++++++++++++++++++++ src/XTerm.NET/Common/ColorSpec.cs | 168 ++++++++++++ src/XTerm.NET/InputHandler.cs | 126 ++++++--- src/XTerm.NET/Terminal.cs | 15 ++ 5 files changed, 906 insertions(+), 31 deletions(-) create mode 100644 src/XTerm.NET.Tests/ColorPaletteTests.cs create mode 100644 src/XTerm.NET/Common/ColorPalette.cs create mode 100644 src/XTerm.NET/Common/ColorSpec.cs diff --git a/src/XTerm.NET.Tests/ColorPaletteTests.cs b/src/XTerm.NET.Tests/ColorPaletteTests.cs new file mode 100644 index 0000000..357ce17 --- /dev/null +++ b/src/XTerm.NET.Tests/ColorPaletteTests.cs @@ -0,0 +1,327 @@ +using XTerm; +using XTerm.Common; +using XTerm.Options; + +namespace XTerm.Tests; + +/// +/// Covers OSC 4 (indexed palette), OSC 10/11/12 (foreground, background, cursor), and the +/// OSC 104/110/111/112 resets. +/// +public class ColorPaletteTests +{ + private Terminal CreateTerminal(ThemeOptions? theme = null) + { + var options = new TerminalOptions { Cols = 80, Rows = 24 }; + if (theme is not null) + { + options.Theme = theme; + } + + return new Terminal(options); + } + + private static List CaptureReplies(Terminal terminal) + { + var replies = new List(); + terminal.DataReceived += (_, e) => replies.Add(e.Data); + return replies; + } + + // ---- defaults ---------------------------------------------------------------------------- + + [Fact] + public void Defaults_MatchXtermAnsiColors() + { + var terminal = CreateTerminal(); + + Assert.Equal(0x000000, terminal.Colors[0]); + Assert.Equal(0xCD0000, terminal.Colors[1]); + Assert.Equal(0xFFFFFF, terminal.Colors[15]); + } + + [Fact] + public void Defaults_ComputeThe216ColorCube() + { + var terminal = CreateTerminal(); + + Assert.Equal(0x000000, terminal.Colors[16]); // cube origin + Assert.Equal(0xFFFFFF, terminal.Colors[231]); // cube far corner + Assert.Equal(0x005FAF, terminal.Colors[25]); // r=0 g=1 b=3 -> 0,95,175 + } + + [Fact] + public void Defaults_ComputeTheGrayscaleRamp() + { + var terminal = CreateTerminal(); + + Assert.Equal(0x080808, terminal.Colors[232]); + Assert.Equal(0xEEEEEE, terminal.Colors[255]); + } + + [Fact] + public void Defaults_PreserveThePreviousQueryAnswers_WhenNoThemeIsSet() + { + // This change must not move colours for an embedder that sets no theme; it only stops the + // answers being constants. + var terminal = CreateTerminal(); + + Assert.Equal(0xFFFFFF, terminal.Colors.Foreground); + Assert.Equal(0x000000, terminal.Colors.Background); + Assert.Equal(0xFFFFFF, terminal.Colors.Cursor); + } + + // ---- the light background case ------------------------------------------------------------ + + [Fact] + public void Theme_SetsTheBackground_AndTheQueryReportsIt() + { + // The reason this PR exists. A program asks what the background is before choosing its own + // colours; a constant reply of black made every one of them render for a dark terminal. + var terminal = CreateTerminal(new ThemeOptions { Background = "#ffffff", Foreground = "#000000" }); + var replies = CaptureReplies(terminal); + + terminal.Write("\x1B]11;?\x07"); + + Assert.Equal("\u001b]11;rgb:ffff/ffff/ffff\u0007", Assert.Single(replies)); + } + + [Fact] + public void IsLightBackground_FollowsTheTheme() + { + Assert.True(CreateTerminal(new ThemeOptions { Background = "#ffffff" }).Colors.IsLightBackground); + Assert.False(CreateTerminal(new ThemeOptions { Background = "#000000" }).Colors.IsLightBackground); + + // Luma-weighted rather than averaged: pure blue is dark despite a high channel value. + Assert.False(CreateTerminal(new ThemeOptions { Background = "#0000ff" }).Colors.IsLightBackground); + } + + [Fact] + public void ApplyTheme_ReseedsAtRuntime_ForAnOsThemeFlip() + { + var terminal = CreateTerminal(new ThemeOptions { Background = "#000000" }); + Assert.False(terminal.Colors.IsLightBackground); + + terminal.Colors.ApplyTheme(new ThemeOptions { Background = "#ffffff", Foreground = "#000000" }); + + Assert.True(terminal.Colors.IsLightBackground); + Assert.Equal(0x000000, terminal.Colors.Foreground); + } + + [Fact] + public void Reset_RestoresTheEmbedderTheme_NotAFactoryDarkPalette() + { + // The failure this guards: a program sets colours, then resets, and a light terminal is + // left black because "reset" meant xterm's defaults rather than the configured theme. + var terminal = CreateTerminal(new ThemeOptions { Background = "#ffffff", Black = "#eeeeee" }); + + terminal.Write("\x1B]11;rgb:00/00/00\x07"); + terminal.Write("\x1B]4;0;#123456\x07"); + Assert.Equal(0x000000, terminal.Colors.Background); + + terminal.Write("\x1B]111\x07"); + terminal.Write("\x1B]104\x07"); + + Assert.Equal(0xFFFFFF, terminal.Colors.Background); + Assert.Equal(0xEEEEEE, terminal.Colors[0]); + } + + [Fact] + public void Theme_OverridesAnsiSlots() + { + var terminal = CreateTerminal(new ThemeOptions { Red = "#ff8800", BrightWhite = "rgb:11/22/33" }); + + Assert.Equal(0xFF8800, terminal.Colors[1]); + Assert.Equal(0x112233, terminal.Colors[15]); + } + + // ---- OSC 4 ------------------------------------------------------------------------------- + + [Fact] + public void Osc4_SetsAnIndexedColor() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]4;1;rgb:ff/00/00\x07"); + + Assert.Equal(0xFF0000, terminal.Colors[1]); + } + + [Fact] + public void Osc4_SetsMultiplePairsInOneSequence() + { + // Theme scripts send all sixteen at once rather than as sixteen sequences. + var terminal = CreateTerminal(); + + terminal.Write("\x1B]4;1;#ff0000;2;#00ff00;3;#0000ff\x07"); + + Assert.Equal(0xFF0000, terminal.Colors[1]); + Assert.Equal(0x00FF00, terminal.Colors[2]); + Assert.Equal(0x0000FF, terminal.Colors[3]); + } + + [Fact] + public void Osc4_QueriesAnIndexedColor() + { + var terminal = CreateTerminal(); + var replies = CaptureReplies(terminal); + + terminal.Write("\x1B]4;1;?\x07"); + + Assert.Equal("\u001b]4;1;rgb:cdcd/0000/0000\u0007", Assert.Single(replies)); + } + + [Fact] + public void Osc4_QueryReflectsAPriorSet() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]4;5;#010203\x07"); + var replies = CaptureReplies(terminal); + + terminal.Write("\x1B]4;5;?\x07"); + + Assert.Equal("\u001b]4;5;rgb:0101/0202/0303\u0007", Assert.Single(replies)); + } + + [Fact] + public void Osc4_IgnoresOutOfRangeAndMalformedEntries() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]4;999;#ffffff\x07"); + terminal.Write("\x1B]4;1;notacolor\x07"); + + Assert.Equal(0xCD0000, terminal.Colors[1]); + } + + [Fact] + public void Osc104_ResetsASingleIndex() + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]4;1;#ffffff\x07"); + terminal.Write("\x1B]4;2;#ffffff\x07"); + + terminal.Write("\x1B]104;1\x07"); + + Assert.Equal(0xCD0000, terminal.Colors[1]); + Assert.Equal(0xFFFFFF, terminal.Colors[2]); + } + + // ---- OSC 10/11/12 ------------------------------------------------------------------------ + + [Fact] + public void Osc10_SetsForeground() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]10;#abcdef\x07"); + + Assert.Equal(0xABCDEF, terminal.Colors.Foreground); + } + + [Fact] + public void Osc10_WithMultipleSpecs_AdvancesThroughResources() + { + // xterm defines OSC 10 ; fg ; bg as setting both; handling only the first drops the + // background silently. + var terminal = CreateTerminal(); + + terminal.Write("\x1B]10;#111111;#222222;#333333\x07"); + + Assert.Equal(0x111111, terminal.Colors.Foreground); + Assert.Equal(0x222222, terminal.Colors.Background); + Assert.Equal(0x333333, terminal.Colors.Cursor); + } + + [Fact] + public void Osc12_SetsCursor() + { + var terminal = CreateTerminal(); + + terminal.Write("\x1B]12;red\x07"); + + Assert.Equal(0xFF0000, terminal.Colors.Cursor); + } + + [Theory] + [InlineData("110", 0xFFFFFF)] + [InlineData("111", 0x000000)] + [InlineData("112", 0xFFFFFF)] + public void Osc110To112_ResetTheirOwnResource(string code, int expected) + { + var terminal = CreateTerminal(); + terminal.Write("\x1B]10;#123456\x07"); + terminal.Write("\x1B]11;#123456\x07"); + terminal.Write("\x1B]12;#123456\x07"); + + terminal.Write($"\x1B]{code}\x07"); + + var actual = code switch + { + "110" => terminal.Colors.Foreground, + "111" => terminal.Colors.Background, + _ => terminal.Colors.Cursor, + }; + Assert.Equal(expected, actual); + } + + [Fact] + public void ColorChanged_FiresForSetsAndNotForNoOps() + { + var terminal = CreateTerminal(); + var changes = new List(); + terminal.Colors.ColorChanged += (_, e) => changes.Add(e); + + terminal.Write("\x1B]4;1;#ff0000\x07"); + terminal.Write("\x1B]4;1;#ff0000\x07"); // same value, no repaint needed + + var change = Assert.Single(changes); + Assert.Equal(ColorTarget.Indexed, change.Target); + Assert.Equal(1, change.Index); + Assert.Equal(0xFF0000, change.Rgb); + } + + // ---- colour spec parsing ----------------------------------------------------------------- + + [Theory] + [InlineData("rgb:ff/00/00", 0xFF0000)] + [InlineData("rgb:f/0/0", 0xFF0000)] // 1 digit: f is FULL intensity, not 0x0f + [InlineData("rgb:ffff/0000/0000", 0xFF0000)] // 4 digits, as emitted by queries + [InlineData("#ff0000", 0xFF0000)] + [InlineData("#f00", 0xFF0000)] + [InlineData("#ffff00000000", 0xFF0000)] + [InlineData("red", 0xFF0000)] + [InlineData("RED", 0xFF0000)] + public void ColorSpec_ParsesTheFormsProgramsActuallySend(string spec, int expected) + { + Assert.True(ColorSpec.TryParse(spec, out var rgb)); + Assert.Equal(expected, rgb); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + [InlineData("rgb:ff/00")] + [InlineData("rgb:gg/00/00")] + [InlineData("#ff00")] + [InlineData("chartreuse")] + public void ColorSpec_RejectsWhatItCannotRead(string? spec) + { + Assert.False(ColorSpec.TryParse(spec, out _)); + } + + [Fact] + public void ColorSpec_FormatWidensChannelsByRepetition() + { + // 0xff must become 0xffff, not 0xff00: full intensity has to survive the widening. + Assert.Equal("rgb:ffff/0000/8080", ColorSpec.Format(0xFF0080)); + } + + [Fact] + public void ColorSpec_RoundTripsThroughFormatAndParse() + { + Assert.True(ColorSpec.TryParse(ColorSpec.Format(0x123456), out var rgb)); + Assert.Equal(0x123456, rgb); + } +} diff --git a/src/XTerm.NET/Common/ColorPalette.cs b/src/XTerm.NET/Common/ColorPalette.cs new file mode 100644 index 0000000..bc28299 --- /dev/null +++ b/src/XTerm.NET/Common/ColorPalette.cs @@ -0,0 +1,301 @@ +using XTerm.Options; + +namespace XTerm.Common; + +/// +/// The terminal's 256-entry colour palette plus its foreground, background and cursor colours, +/// as read by OSC 4 and OSC 10/11/12 and reset by OSC 104 and OSC 110/111/112. +/// +/// +/// Two layers, and the distinction is the whole point of the type. DEFAULTS come from +/// , i.e. from the embedder. CURRENT values are what programs have set +/// over the top with OSC. A reset returns to the defaults, so it restores the EMBEDDER'S theme +/// rather than some factory dark palette -- otherwise any program calling OSC 104 would drag a +/// light terminal back to black, which is exactly the bug that makes light themes unusable. +/// +public class ColorPalette +{ + /// + /// Number of indexed colours. 0-15 are the ANSI colours, 16-231 a 6x6x6 cube, 232-255 a + /// greyscale ramp. + /// + public const int Size = 256; + + private readonly int[] defaults = new int[Size]; + private readonly int[] current = new int[Size]; + + private int defaultForeground; + private int defaultBackground; + private int defaultCursor; + + private int foregroundBacking; + private int backgroundBacking; + private int cursorBacking; + + /// + /// Initializes a palette with the built-in xterm defaults. + /// + public ColorPalette() + : this(null) + { + } + + /// + /// Initializes a palette, taking defaults from wherever it specifies + /// one and falling back to the xterm defaults elsewhere. + /// + public ColorPalette(ThemeOptions? theme) + { + ApplyTheme(theme); + } + + /// + /// Fired whenever a colour changes, so a renderer can repaint. Not raised when a set is a no-op. + /// + public event EventHandler? ColorChanged; + + /// + /// The current foreground colour, as 0xRRGGBB. + /// + public int Foreground => foregroundBacking; + + /// + /// The current background colour, as 0xRRGGBB. + /// + public int Background => backgroundBacking; + + /// + /// The current cursor colour, as 0xRRGGBB. + /// + public int Cursor => cursorBacking; + + /// + /// Whether the background is light enough that a program should choose dark text. + /// + /// + /// Uses the ITU-R BT.601 luma of the background against a mid threshold. Offered because every + /// consumer that wants it would otherwise write the same formula, and would be likely to write + /// it as a plain average of the channels -- which calls pure blue light. + /// + public bool IsLightBackground => Luma(Background) > 0.5; + + /// + /// Gets the current colour for an index, as 0xRRGGBB. + /// + public int this[int index] => current[Clamp(index)]; + + /// + /// Replaces the defaults from a theme and discards any colours programs had set. + /// + /// + /// The runtime path for an embedder following the OS light/dark setting: call this when the + /// system theme flips. Everything is re-seeded, because a palette half in the old theme and + /// half in the new one is not a theme. + /// + public void ApplyTheme(ThemeOptions? theme) + { + SeedDefaults(theme); + Array.Copy(defaults, current, Size); + foregroundBacking = defaultForeground; + backgroundBacking = defaultBackground; + cursorBacking = defaultCursor; + ColorChanged?.Invoke(this, new ColorChangedEventArgs(ColorTarget.All, -1, 0)); + } + + /// + /// Sets one indexed colour, as OSC 4 does. + /// + public void SetColor(int index, int rgb) + { + index = Clamp(index); + if (current[index] == rgb) + { + return; + } + + current[index] = rgb; + ColorChanged?.Invoke(this, new ColorChangedEventArgs(ColorTarget.Indexed, index, rgb)); + } + + /// + /// Sets the foreground colour, as OSC 10 does. + /// + public void SetForeground(int rgb) => Set(ref foregroundBacking, rgb, ColorTarget.Foreground); + + /// + /// Sets the background colour, as OSC 11 does. + /// + public void SetBackground(int rgb) => Set(ref backgroundBacking, rgb, ColorTarget.Background); + + /// + /// Sets the cursor colour, as OSC 12 does. + /// + public void SetCursor(int rgb) => Set(ref cursorBacking, rgb, ColorTarget.Cursor); + + /// + /// Restores one indexed colour to its default, as OSC 104 with a parameter does. + /// + public void ResetColor(int index) + { + index = Clamp(index); + SetColor(index, defaults[index]); + } + + /// + /// Restores every indexed colour to its default, as bare OSC 104 does. + /// + public void ResetAllColors() + { + Array.Copy(defaults, current, Size); + ColorChanged?.Invoke(this, new ColorChangedEventArgs(ColorTarget.Indexed, -1, 0)); + } + + /// + /// Restores the foreground colour, as OSC 110 does. + /// + public void ResetForeground() => SetForeground(defaultForeground); + + /// + /// Restores the background colour, as OSC 111 does. + /// + public void ResetBackground() => SetBackground(defaultBackground); + + /// + /// Restores the cursor colour, as OSC 112 does. + /// + public void ResetCursor() => SetCursor(defaultCursor); + + private static double Luma(int rgb) + { + var r = ((rgb >> 16) & 0xFF) / 255.0; + var g = ((rgb >> 8) & 0xFF) / 255.0; + var b = (rgb & 0xFF) / 255.0; + return (0.299 * r) + (0.587 * g) + (0.114 * b); + } + + private static int Clamp(int index) => index < 0 ? 0 : index >= Size ? Size - 1 : index; + + private void Set(ref int backing, int rgb, ColorTarget target) + { + if (backing == rgb) + { + return; + } + + backing = rgb; + ColorChanged?.Invoke(this, new ColorChangedEventArgs(target, -1, rgb)); + } + + private void SeedDefaults(ThemeOptions? theme) + { + // 0-15: the xterm ANSI defaults, each overridable by the theme. + var ansi = new[] + { + 0x000000, 0xCD0000, 0x00CD00, 0xCDCD00, 0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5, + 0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00, 0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF, + }; + + var overrides = theme is null + ? new string?[16] + : new[] + { + theme.Black, theme.Red, theme.Green, theme.Yellow, + theme.Blue, theme.Magenta, theme.Cyan, theme.White, + theme.BrightBlack, theme.BrightRed, theme.BrightGreen, theme.BrightYellow, + theme.BrightBlue, theme.BrightMagenta, theme.BrightCyan, theme.BrightWhite, + }; + + for (var i = 0; i < 16; i++) + { + defaults[i] = ColorSpec.TryParse(overrides[i], out var themed) ? themed : ansi[i]; + } + + // 16-231: the 6x6x6 cube. Levels are xterm's, which are not evenly spaced -- the step from + // 0 to 95 is larger than the rest so that the darkest cell is properly black. + var levels = new[] { 0, 95, 135, 175, 215, 255 }; + for (var r = 0; r < 6; r++) + { + for (var g = 0; g < 6; g++) + { + for (var b = 0; b < 6; b++) + { + defaults[16 + (36 * r) + (6 * g) + b] = + (levels[r] << 16) | (levels[g] << 8) | levels[b]; + } + } + } + + // 232-255: the greyscale ramp, deliberately excluding both pure black and pure white, + // which already exist in the cube. + for (var i = 0; i < 24; i++) + { + var v = 8 + (i * 10); + defaults[232 + i] = (v << 16) | (v << 8) | v; + } + + // Defaults chosen to match what this terminal previously answered colour queries with, so + // an embedder that sets no theme sees no change from this. + defaultForeground = ColorSpec.TryParse(theme?.Foreground, out var fg) ? fg : 0xFFFFFF; + defaultBackground = ColorSpec.TryParse(theme?.Background, out var bg) ? bg : 0x000000; + defaultCursor = ColorSpec.TryParse(theme?.Cursor, out var cur) ? cur : 0xFFFFFF; + } +} + +/// +/// Which colour a notification is about. +/// +public enum ColorTarget +{ + /// + /// An indexed palette colour. Index -1 means every entry changed at once. + /// + Indexed, + + /// + /// The foreground colour. + /// + Foreground, + + /// + /// The background colour. + /// + Background, + + /// + /// The cursor colour. + /// + Cursor, + + /// + /// Everything was re-seeded, typically by a theme change. + /// + All, +} + +/// +/// Describes a colour change. +/// +public class ColorChangedEventArgs : EventArgs +{ + public ColorChangedEventArgs(ColorTarget target, int index, int rgb) + { + Target = target; + Index = index; + Rgb = rgb; + } + + /// + /// Which colour changed. + /// + public ColorTarget Target { get; } + + /// + /// The palette index for , or -1 when the change was wholesale. + /// + public int Index { get; } + + /// + /// The new colour as 0xRRGGBB. Not meaningful when is -1. + /// + public int Rgb { get; } +} diff --git a/src/XTerm.NET/Common/ColorSpec.cs b/src/XTerm.NET/Common/ColorSpec.cs new file mode 100644 index 0000000..6733773 --- /dev/null +++ b/src/XTerm.NET/Common/ColorSpec.cs @@ -0,0 +1,168 @@ +using System.Globalization; + +namespace XTerm.Common; + +/// +/// Parsing and formatting for the colour specifications used by OSC 4 and OSC 10/11/12. +/// +/// +/// Colours are carried as 0xRRGGBB. The wire formats are X11's, and a terminal that only accepts +/// one of them looks broken for half the programs that set colours: rgb: is what xterm's own +/// documentation uses, # is what most theme scripts emit, and bare names are what shell snippets +/// tend to hand-write. +/// +public static class ColorSpec +{ + /// + /// Parses an X11 colour specification into 0xRRGGBB. + /// + /// + /// Accepts: + /// rgb:R/G/B with 1 to 4 hex digits per channel + /// #RGB #RRGGBB #RRRRGGGGBBBB + /// a colour name from the small set below + /// + public static bool TryParse(string? spec, out int rgb) + { + rgb = 0; + if (string.IsNullOrWhiteSpace(spec)) + { + return false; + } + + spec = spec.Trim(); + + if (spec.StartsWith("rgb:", StringComparison.OrdinalIgnoreCase)) + { + return TryParseRgbForm(spec.Substring(4), out rgb); + } + + if (spec[0] == '#') + { + return TryParseHashForm(spec.Substring(1), out rgb); + } + + return NamedColors.TryGetValue(spec, out rgb); + } + + /// + /// Formats a colour as the reply body for an OSC colour query. + /// + /// + /// Four hex digits per channel, which is what xterm emits and therefore what programs that + /// probe a terminal are written to read. Each 8-bit channel is widened by repetition rather + /// than by shifting, so 0xff becomes 0xffff and not 0xff00 -- full intensity has to stay full. + /// + public static string Format(int rgb) + { + var r = (rgb >> 16) & 0xFF; + var g = (rgb >> 8) & 0xFF; + var b = rgb & 0xFF; + return string.Create( + CultureInfo.InvariantCulture, + $"rgb:{r:x2}{r:x2}/{g:x2}{g:x2}/{b:x2}{b:x2}"); + } + + private static bool TryParseRgbForm(string body, out int rgb) + { + rgb = 0; + var parts = body.Split('/'); + if (parts.Length != 3) + { + return false; + } + + var channels = new int[3]; + for (var i = 0; i < 3; i++) + { + if (!TryParseChannel(parts[i], out channels[i])) + { + return false; + } + } + + rgb = (channels[0] << 16) | (channels[1] << 8) | channels[2]; + return true; + } + + private static bool TryParseChannel(string text, out int value) + { + value = 0; + if (text.Length is < 1 or > 4) + { + return false; + } + + if (!int.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var raw)) + { + return false; + } + + // Scale to 8 bits by the width actually written: "f" is full intensity in a 1-digit + // channel, and truncating instead would turn it into 0x0f. + var max = (1 << (4 * text.Length)) - 1; + value = (int)Math.Round(raw * 255.0 / max); + return true; + } + + private static bool TryParseHashForm(string body, out int rgb) + { + rgb = 0; + if (body.Length % 3 != 0) + { + return false; + } + + var width = body.Length / 3; + if (width is < 1 or > 4) + { + return false; + } + + var channels = new int[3]; + for (var i = 0; i < 3; i++) + { + if (!TryParseChannel(body.Substring(i * width, width), out channels[i])) + { + return false; + } + } + + rgb = (channels[0] << 16) | (channels[1] << 8) | channels[2]; + return true; + } + + /// + /// The colour names common enough to matter. Not the full X11 list, which runs to hundreds of + /// entries almost none of which are ever sent to a terminal. + /// + private static readonly Dictionary NamedColors = new(StringComparer.OrdinalIgnoreCase) + { + ["black"] = 0x000000, + ["red"] = 0xFF0000, + ["green"] = 0x008000, + ["yellow"] = 0xFFFF00, + ["blue"] = 0x0000FF, + ["magenta"] = 0xFF00FF, + ["cyan"] = 0x00FFFF, + ["white"] = 0xFFFFFF, + ["gray"] = 0x808080, + ["grey"] = 0x808080, + ["darkgray"] = 0xA9A9A9, + ["darkgrey"] = 0xA9A9A9, + ["lightgray"] = 0xD3D3D3, + ["lightgrey"] = 0xD3D3D3, + ["maroon"] = 0x800000, + ["olive"] = 0x808000, + ["navy"] = 0x000080, + ["purple"] = 0x800080, + ["teal"] = 0x008080, + ["silver"] = 0xC0C0C0, + ["lime"] = 0x00FF00, + ["aqua"] = 0x00FFFF, + ["fuchsia"] = 0xFF00FF, + ["orange"] = 0xFFA500, + ["pink"] = 0xFFC0CB, + ["brown"] = 0xA52A2A, + }; +} diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index 4565e30..bb1e743 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -639,7 +639,7 @@ public void HandleOsc(string data) case OscCommand.ResetForeground: case OscCommand.ResetBackground: case OscCommand.ResetCursor: - HandleColorReset(arg); + HandleColorReset(command, arg); break; default: @@ -657,15 +657,30 @@ public void HandleOsc(string data) private void HandleColorPaletteChange(string data) { - // OSC 4 ; index ; colorspec ST - // Example: OSC 4;1;rgb:ff/00/00 ST (set color 1 to red) - // For now, we just acknowledge but don't actually change colors - // A full implementation would parse the color and store it + // OSC 4 ; index ; spec [ ; index ; spec ]... ST + // Pairs, plural: xterm accepts any number in one sequence, and theme scripts routinely send + // all sixteen ANSI colours at once rather than as sixteen sequences. var parts = data.Split(';'); - if (parts.Length >= 2) + + for (var i = 0; i + 1 < parts.Length; i += 2) { - // Color index in parts[0], color spec in parts[1] - // TODO: Implement actual color storage and management + if (!int.TryParse(parts[i], out var index) || index < 0 || index >= ColorPalette.Size) + { + continue; + } + + if (parts[i + 1] == "?") + { + // Answering with the CURRENT colour, not a constant. A program asking this is + // usually about to pick its own colours to match. + _terminal.RaiseDataReceived($"\u001b]4;{index};{ColorSpec.Format(_terminal.Colors[index])}\u0007"); + continue; + } + + if (ColorSpec.TryParse(parts[i + 1], out var rgb)) + { + _terminal.Colors.SetColor(index, rgb); + } } } @@ -731,30 +746,49 @@ private void HandleHyperlink(string data) private void HandleColorQuery(string colorType, string data) { - // OSC 10/11/12 with ? queries the color - // OSC 10/11/12 with color spec sets the color - if (data == "?") + // OSC 10/11/12 ; spec [ ; spec ]... ST - set, or query when spec is "?" + // + // Multiple specs advance through the resources in order, so OSC 10 ; fg ; bg sets the + // foreground AND the background. xterm defines it that way and shell prompts written for + // xterm use it, so handling only the first would set the foreground and silently drop the + // background. + if (!int.TryParse(colorType, out var resource)) { - // Query color - respond with current color - // Format: OSC colorType ; rgb:rr/gg/bb ST - // For now, return a default response - string response = colorType switch + return; + } + + foreach (var spec in data.Split(';')) + { + if (resource > 12) { - "10" => $"\u001b]{colorType};rgb:ff/ff/ff\u0007", // Foreground - "11" => $"\u001b]{colorType};rgb:00/00/00\u0007", // Background - "12" => $"\u001b]{colorType};rgb:ff/ff/ff\u0007", // Cursor - _ => string.Empty - }; + break; + } - if (!string.IsNullOrEmpty(response)) + if (spec == "?") { - _terminal.RaiseDataReceived(response); + var current = resource switch + { + 10 => _terminal.Colors.Foreground, + 11 => _terminal.Colors.Background, + _ => _terminal.Colors.Cursor, + }; + + // The real colour, not a constant. Programs query OSC 11 to decide whether they are + // on a light or a dark terminal; answering black regardless told every one of them + // "dark", and a light theme got dark-theme colours drawn onto it. + _terminal.RaiseDataReceived($"\u001b]{resource};{ColorSpec.Format(current)}\u0007"); } - } - else if (!string.IsNullOrEmpty(data)) - { - // Set color - would parse and apply the color - // TODO: Implement actual color setting + else if (ColorSpec.TryParse(spec, out var rgb)) + { + switch (resource) + { + case 10: _terminal.Colors.SetForeground(rgb); break; + case 11: _terminal.Colors.SetBackground(rgb); break; + case 12: _terminal.Colors.SetCursor(rgb); break; + } + } + + resource++; } } @@ -795,11 +829,41 @@ private void HandleClipboard(string data) } } - private void HandleColorReset(string data) + private void HandleColorReset(OscCommand command, string data) { - // OSC 104 ; index ST (reset specific color) - // OSC 104 ST (reset all colors) - // TODO: Implement color reset functionality + // OSC 104 [ ; index ]... ST - reset palette entries, or all of them when bare + // OSC 110/111/112 ST - reset foreground / background / cursor + // + // "Reset" means back to the EMBEDDER'S theme, not to a factory dark palette. Anything else + // and a program calling OSC 104 would drag a light terminal to black and leave it there. + switch (command) + { + case OscCommand.ResetForeground: + _terminal.Colors.ResetForeground(); + return; + + case OscCommand.ResetBackground: + _terminal.Colors.ResetBackground(); + return; + + case OscCommand.ResetCursor: + _terminal.Colors.ResetCursor(); + return; + } + + if (string.IsNullOrEmpty(data)) + { + _terminal.Colors.ResetAllColors(); + return; + } + + foreach (var part in data.Split(';')) + { + if (int.TryParse(part, out var index) && index >= 0 && index < ColorPalette.Size) + { + _terminal.Colors.ResetColor(index); + } + } } // CSI Handler Implementations diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index a66ef2d..87eb631 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -65,6 +65,20 @@ public class Terminal public string Title { get; set; } public string? CurrentDirectory { get; set; } public string? CurrentHyperlink { get; set; } + + /// + /// The terminal's colours: the 256-entry palette plus foreground, background and cursor. + /// + /// + /// Seeded from , then modified by OSC 4 and OSC 10/11/12. + /// An embedder following the OS light/dark setting calls + /// when it flips. + /// + /// This is also what colour QUERIES answer from, which is the point: a program that asks for + /// the background before choosing its own palette gets the real one, so a light terminal stops + /// being told to render for a dark one. + /// + public ColorPalette Colors { get; } public string? HyperlinkId { get; set; } /// @@ -175,6 +189,7 @@ public Terminal(TerminalOptions? options = null) Cols = Options.Cols; Rows = Options.Rows; Title = string.Empty; + Colors = new ColorPalette(Options.Theme); // Initialize buffers _normalBuffer = new Buffer.TerminalBuffer(Cols, Rows, Options.Scrollback);