diff --git a/App/MainWindow.cs b/App/MainWindow.cs index d363a3e..433b7b8 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -415,15 +415,15 @@ private void OnThemeChanged(AppTheme theme) private string GetThemeToggleTitle() { - // Show what clicking will do: "Switch to Light" when in Dark, "Switch to Dark" when in Light - var currentIndex = ThemeManager.GetCurrentThemeIndex(); - return currentIndex == 0 ? "Switch to _Light" : "Switch to _Dark"; + // Show what clicking will do: cycle Dark -> Light -> Terminal -> Dark + var themes = ThemeManager.AvailableThemes; + var nextIndex = (ThemeManager.GetCurrentThemeIndex() + 1) % themes.Count; + return $"Switch to _{themes[nextIndex].Name}"; } private void ToggleTheme() { - var currentIndex = ThemeManager.GetCurrentThemeIndex(); - var newIndex = (currentIndex + 1) % 2; + var newIndex = (ThemeManager.GetCurrentThemeIndex() + 1) % ThemeManager.AvailableThemes.Count; ThemeManager.SetThemeByIndex(newIndex); } diff --git a/App/Themes/AppTheme.cs b/App/Themes/AppTheme.cs index 7d07b1f..540edab 100644 --- a/App/Themes/AppTheme.cs +++ b/App/Themes/AppTheme.cs @@ -14,6 +14,14 @@ public abstract class AppTheme public abstract string Name { get; } public abstract string Description { get; } + /// + /// When true, the application restricts output to the 16 ANSI colors + /// (via ) so the terminal renders + /// the theme using its own configured ANSI palette. Themes setting this + /// should define all colors using values. + /// + public virtual bool UseTerminalColors => false; + // === Base Colors === public abstract Color Background { get; } public abstract Color Foreground { get; } diff --git a/App/Themes/TerminalTheme.cs b/App/Themes/TerminalTheme.cs new file mode 100644 index 0000000..bc219fb --- /dev/null +++ b/App/Themes/TerminalTheme.cs @@ -0,0 +1,105 @@ +using Terminal.Gui; +using Attribute = Terminal.Gui.Attribute; + +namespace Opcilloscope.App.Themes; + +/// +/// Theme that inherits the terminal's own ANSI color palette. +/// Uses only the 16 named ANSI colors and enables Force16Colors so the +/// driver emits standard SGR color codes (30-37/90-97) instead of 24-bit +/// RGB - the terminal then renders them with its configured color scheme. +/// +public class TerminalTheme : AppTheme +{ + public override string Name => "Terminal"; + public override string Description => "Inherits the terminal's ANSI color palette"; + + // Output only the 16 ANSI colors so the terminal applies its own palette + public override bool UseTerminalColors => true; + + // Main window uses double-line for emphasis, frames use single + public override LineStyle BorderLineStyle => LineStyle.Double; + public override LineStyle FrameLineStyle => LineStyle.Single; + public override LineStyle EmphasizedBorderStyle => LineStyle.Double; + public override LineStyle SecondaryBorderStyle => LineStyle.Single; + + // All colors are constructed from ColorName16 enum values, so + // GetClosestNamedColor16 resolves them to the exact ANSI index + // (no nearest-color approximation) + + public override Color Background => new(ColorName16.Black); // ANSI 0 + + public override Color Foreground => new(ColorName16.Gray); // ANSI 7 + public override Color ForegroundBright => new(ColorName16.White); // ANSI 15 + public override Color ForegroundDim => new(ColorName16.DarkGray); // ANSI 8 + + // Yellow is the closest ANSI color to opcilloscope's signature amber + public override Color Accent => new(ColorName16.Yellow); // ANSI 3 + public override Color AccentBright => new(ColorName16.BrightYellow); // ANSI 11 + + public override Color Border => new(ColorName16.DarkGray); + public override Color Grid => new(ColorName16.DarkGray); + + public override Color StatusActive => new(ColorName16.Green); // ANSI 2 + public override Color StatusInactive => new(ColorName16.DarkGray); + public override Color Error => new(ColorName16.Red); // ANSI 1 + public override Color Warning => new(ColorName16.Yellow); + + // OPC UA Status Colors + public override Color StatusGood => new(ColorName16.Green); + public override Color StatusBad => new(ColorName16.Red); + public override Color StatusUncertain => new(ColorName16.Yellow); + + // Muted text for timestamps, attribution + public override Color MutedText => new(ColorName16.DarkGray); + + // Single-line box drawing for clean look + public override char BoxTopLeft => '┌'; + public override char BoxTopRight => '┐'; + public override char BoxBottomLeft => '└'; + public override char BoxBottomRight => '┘'; + public override char BoxHorizontal => '─'; + public override char BoxVertical => '│'; + public override char BoxTitleLeft => '┤'; + public override char BoxTitleRight => '├'; + public override char TickHorizontal => '┬'; + public override char TickVertical => '├'; + public override char TickHorizontalBottom => '┴'; + public override char TickVerticalRight => '┤'; + public override char BoxLeftT => '├'; + public override char BoxRightT => '┤'; + + // Minimal decorations matching the other themes + public override string ButtonPrefix => "[ "; + public override string ButtonSuffix => " ]"; + public override string TitleDecoration => "───"; + public override string StatusLive => "◆ LIVE"; + public override string StatusHold => "◇ HOLD"; + public override string NoSignalMessage => "· NO SIGNAL ·"; + + // Glow uses White which maps cleanly to ANSI 15 + public override bool EnableGlow => true; + + // Override color schemes - focus highlight uses a DarkGray band since + // panel-background shades are not available in the 16-color palette + private ColorScheme? _mainColorScheme; + private ColorScheme? _menuColorScheme; + + public override ColorScheme MainColorScheme => _mainColorScheme ??= new() + { + Normal = NormalAttr, + Focus = new Attribute(ForegroundBright, new Color(ColorName16.DarkGray)), + HotNormal = AccentAttr, + HotFocus = new Attribute(AccentBright, new Color(ColorName16.DarkGray)), + Disabled = new Attribute(StatusInactive, Background) + }; + + public override ColorScheme MenuColorScheme => _menuColorScheme ??= new() + { + Normal = NormalAttr, + Focus = new Attribute(Background, Foreground), // Inverted for menu focus + HotNormal = AccentAttr, + HotFocus = new Attribute(Background, Accent), + Disabled = new Attribute(StatusInactive, Background) + }; +} diff --git a/App/Themes/ThemeManager.cs b/App/Themes/ThemeManager.cs index 0e9ccff..efd1ce8 100644 --- a/App/Themes/ThemeManager.cs +++ b/App/Themes/ThemeManager.cs @@ -1,3 +1,5 @@ +using Terminal.Gui; + namespace Opcilloscope.App.Themes; /// @@ -14,7 +16,8 @@ public static class ThemeManager public static IReadOnlyList AvailableThemes { get; } = new AppTheme[] { new DarkTheme(), - new LightTheme() + new LightTheme(), + new TerminalTheme() }; /// @@ -67,9 +70,28 @@ public static void SetTheme(AppTheme theme) handlers = ThemeChanged; } + ApplyTerminalColorMode(themeToUse); + handlers?.Invoke(themeToUse); } + /// + /// Switches the driver between 24-bit color and 16-color ANSI output. + /// In 16-color mode the terminal renders colors using its own ANSI + /// palette, letting the Terminal theme inherit the terminal's scheme. + /// + private static void ApplyTerminalColorMode(AppTheme theme) + { + Application.Force16Colors = theme.UseTerminalColors; + + // The v2 facade driver caches its own flag rather than reading + // Application.Force16Colors, so propagate explicitly when running + if (Application.Driver is { } driver) + { + driver.Force16Colors = theme.UseTerminalColors; + } + } + /// /// Gets theme names for display in UI. /// diff --git a/CLAUDE.md b/CLAUDE.md index c373786..1fd0c91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,7 @@ Opcilloscope/ │ ├── AppTheme.cs # Abstract base theme class │ ├── DarkTheme.cs # Dark theme implementation │ ├── LightTheme.cs # Light theme implementation +│ ├── TerminalTheme.cs # Theme inheriting the terminal's ANSI palette │ ├── ThemeManager.cs # Global theme state and switching │ └── ThemeStyler.cs # Theme application helper │ @@ -207,14 +208,15 @@ Opcilloscope uses JSON-based configuration files with the `.cfg` extension: ``` ### Theme System -Two built-in themes with consistent styling: +Three built-in themes with consistent styling: - **DarkTheme** (default): Dark background, high contrast for terminal use - **LightTheme**: Light background for bright environments +- **TerminalTheme**: Inherits the terminal's own ANSI color palette. Uses only the 16 named ANSI colors (`ColorName16`) and enables `Application.Force16Colors` so the driver emits standard SGR color codes instead of 24-bit RGB — the terminal renders them with its configured scheme. `ThemeManager.SetTheme` toggles `Force16Colors` automatically via `AppTheme.UseTerminalColors`. -Toggle themes via View menu or programmatically: +Toggle themes via View menu (cycles Dark → Light → Terminal) or programmatically: ```csharp -ThemeManager.SetTheme("Light"); -ThemeManager.SetThemeByIndex(0); // 0 = Dark, 1 = Light +ThemeManager.SetTheme("Terminal"); +ThemeManager.SetThemeByIndex(0); // 0 = Dark, 1 = Light, 2 = Terminal ``` ### Scope View (Multi-Signal Oscilloscope) diff --git a/README.md b/README.md index 7d7571d..84bba4a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Browse, monitor, and subscribe to industrial automation data right from your ter - **Scope** — Real-time multi-signal oscilloscope (up to 5 signals, 30 s sliding window). - **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured at full precision in a locale-independent format (ISO 8601 timestamps, `.` decimal separator, arrays as semicolon-joined elements). - **Configure** — Save/load connection and subscription configs (`.cfg` JSON files). -- **Themes** — Dark (default) and light. +- **Themes** — Dark (default), light, and terminal (inherits your terminal's ANSI colour scheme).

Dark theme diff --git a/Tests/Opcilloscope.Tests/App/AppThemeTests.cs b/Tests/Opcilloscope.Tests/App/AppThemeTests.cs index ff7c255..d49af9e 100644 --- a/Tests/Opcilloscope.Tests/App/AppThemeTests.cs +++ b/Tests/Opcilloscope.Tests/App/AppThemeTests.cs @@ -52,6 +52,7 @@ public void LightTheme_HasLightBackground() [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveNonNullColors(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -73,6 +74,7 @@ public void AllThemes_HaveNonNullColors(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveNonNullAttributes(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -85,6 +87,7 @@ public void AllThemes_HaveNonNullAttributes(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveNonNullColorSchemes(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -99,6 +102,7 @@ public void AllThemes_HaveNonNullColorSchemes(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveSingleLineBoxDrawingCharacters(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -115,6 +119,7 @@ public void AllThemes_HaveSingleLineBoxDrawingCharacters(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveUIDecorations(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -127,6 +132,66 @@ public void AllThemes_HaveUIDecorations(Type themeType) Assert.NotNull(theme.NoSignalMessage); } + [Fact] + public void TerminalTheme_HasCorrectName() + { + var theme = new TerminalTheme(); + Assert.Equal("Terminal", theme.Name); + } + + [Fact] + public void TerminalTheme_HasCorrectDescription() + { + var theme = new TerminalTheme(); + Assert.Equal("Inherits the terminal's ANSI color palette", theme.Description); + } + + [Fact] + public void TerminalTheme_UsesTerminalColors() + { + var theme = new TerminalTheme(); + Assert.True(theme.UseTerminalColors); + } + + [Theory] + [InlineData(typeof(DarkTheme))] + [InlineData(typeof(LightTheme))] + public void NonTerminalThemes_DoNotUseTerminalColors(Type themeType) + { + var theme = (AppTheme)Activator.CreateInstance(themeType)!; + Assert.False(theme.UseTerminalColors); + } + + [Fact] + public void TerminalTheme_AllColorsMapExactlyToAnsiColors() + { + var theme = new TerminalTheme(); + + // Every color must round-trip exactly through the 16-color ANSI map + // so the terminal's own palette is used without approximation + var colors = new[] + { + theme.Background, theme.Foreground, theme.ForegroundBright, + theme.ForegroundDim, theme.Accent, theme.AccentBright, + theme.Border, theme.Grid, theme.StatusActive, theme.StatusInactive, + theme.Error, theme.Warning, theme.StatusGood, theme.StatusBad, + theme.StatusUncertain, theme.MutedText + }; + + foreach (var color in colors) + { + var roundTripped = new Color(color.GetClosestNamedColor16()); + Assert.Equal(roundTripped, color); + } + } + + [Fact] + public void TerminalTheme_UsesAnsiBlackBackground() + { + var theme = new TerminalTheme(); + Assert.Equal(new Color(ColorName16.Black), theme.Background); + } + [Fact] public void DarkTheme_HasErrorColor() { @@ -222,6 +287,7 @@ public void ButtonDecorations_AreMinimal() [Theory] [InlineData(typeof(DarkTheme), "Dark")] [InlineData(typeof(LightTheme), "Light")] + [InlineData(typeof(TerminalTheme), "Terminal")] public void AllThemes_NameMatchesExpected(Type themeType, string expectedName) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -264,6 +330,7 @@ public void MenuColorScheme_HasAllRequiredProperties() [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_UseDoubleBorderAndSingleFrame(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -277,6 +344,7 @@ public void AllThemes_UseDoubleBorderAndSingleFrame(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveStatusColors(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; @@ -291,6 +359,7 @@ public void AllThemes_HaveStatusColors(Type themeType) [Theory] [InlineData(typeof(DarkTheme))] [InlineData(typeof(LightTheme))] + [InlineData(typeof(TerminalTheme))] public void AllThemes_HaveConnectionIndicators(Type themeType) { var theme = (AppTheme)Activator.CreateInstance(themeType)!; diff --git a/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs b/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs index ed8d329..31e6ab3 100644 --- a/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs +++ b/Tests/Opcilloscope.Tests/App/ThemeManagerTests.cs @@ -22,9 +22,10 @@ public void ThemeManager_AvailableThemes_ContainsAllThemes() var themes = ThemeManager.AvailableThemes; // Assert - Assert.Equal(2, themes.Count); + Assert.Equal(3, themes.Count); Assert.Contains(themes, t => t is DarkTheme); Assert.Contains(themes, t => t is LightTheme); + Assert.Contains(themes, t => t is TerminalTheme); } [Fact] @@ -121,6 +122,31 @@ public void ThemeManager_SetThemeByName_WithInvalidName_DoesNothing() Assert.Same(currentTheme, ThemeManager.Current); } + [Fact] + public void ThemeManager_SetTerminalTheme_TogglesForce16Colors() + { + try + { + // Act - Terminal theme enables 16-color ANSI output + ThemeManager.SetTheme("Terminal"); + + // Assert + Assert.IsType(ThemeManager.Current); + Assert.True(Terminal.Gui.Application.Force16Colors); + + // Act - switching back restores 24-bit color output + ThemeManager.SetTheme("Dark"); + + // Assert + Assert.False(Terminal.Gui.Application.Force16Colors); + } + finally + { + // Cleanup + ThemeManager.SetTheme(new DarkTheme()); + } + } + [Fact] public void ThemeManager_GetThemeNames_ReturnsAllNames() { @@ -128,9 +154,10 @@ public void ThemeManager_GetThemeNames_ReturnsAllNames() var names = ThemeManager.GetThemeNames(); // Assert - Assert.Equal(2, names.Length); + Assert.Equal(3, names.Length); Assert.Contains("Dark", names); Assert.Contains("Light", names); + Assert.Contains("Terminal", names); } [Fact]