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
10 changes: 5 additions & 5 deletions App/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
8 changes: 8 additions & 0 deletions App/Themes/AppTheme.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ public abstract class AppTheme
public abstract string Name { get; }
public abstract string Description { get; }

/// <summary>
/// When true, the application restricts output to the 16 ANSI colors
/// (via <see cref="Application.Force16Colors"/>) so the terminal renders
/// the theme using its own configured ANSI palette. Themes setting this
/// should define all colors using <see cref="ColorName16"/> values.
/// </summary>
public virtual bool UseTerminalColors => false;

// === Base Colors ===
public abstract Color Background { get; }
public abstract Color Foreground { get; }
Expand Down
105 changes: 105 additions & 0 deletions App/Themes/TerminalTheme.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Terminal.Gui;
using Attribute = Terminal.Gui.Attribute;

namespace Opcilloscope.App.Themes;

/// <summary>
/// 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.
/// </summary>
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)
};
}
24 changes: 23 additions & 1 deletion App/Themes/ThemeManager.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Terminal.Gui;

namespace Opcilloscope.App.Themes;

/// <summary>
Expand All @@ -14,7 +16,8 @@ public static class ThemeManager
public static IReadOnlyList<AppTheme> AvailableThemes { get; } = new AppTheme[]
{
new DarkTheme(),
new LightTheme()
new LightTheme(),
new TerminalTheme()
};

/// <summary>
Expand Down Expand Up @@ -67,9 +70,28 @@ public static void SetTheme(AppTheme theme)
handlers = ThemeChanged;
}

ApplyTerminalColorMode(themeToUse);

handlers?.Invoke(themeToUse);
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Gets theme names for display in UI.
/// </summary>
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<p align="center">
<img src="docs/theme-dark.png" alt="Dark theme" width="49%">
Expand Down
69 changes: 69 additions & 0 deletions Tests/Opcilloscope.Tests/App/AppThemeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand All @@ -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)!;
Expand All @@ -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)!;
Expand All @@ -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)!;
Expand All @@ -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)!;
Expand All @@ -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()
{
Expand Down Expand Up @@ -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)!;
Expand Down Expand Up @@ -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)!;
Expand All @@ -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)!;
Expand All @@ -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)!;
Expand Down
Loading
Loading