diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index bda94dd..2838b92 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -31,6 +31,8 @@ Rows are redgate TSV requirements: `ringidsummary [tag]`. 0 001 Narrow terminals SHALL use transient panels instead of permanent desktop splits. [mobile] 1 002 Files, git, diagnostics, LSP, build, terminal, and Pi surfaces SHALL share one panel navigation model. [panels] 0 003 When the cursor moves beyond the visible horizontal or vertical viewport, the editor SHALL scroll enough to keep the cursor cell visible. [mobile] +0 004 The default visual theme SHALL preserve semantic color identity for syntax, selections, diagnostics, job/tool status, hover/signature/context surfaces, and panel chrome on mobile SSH terminals. [mobile] +0 005 Terminal color adaptation SHALL choose truecolor, 256-color, 16-color, or mono output from local terminal environment signals with no runtime config files. [mobile] ## coding diff --git a/src/theme.zig b/src/theme.zig index 8ace9a4..ea9ccec 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -39,6 +39,25 @@ pub const ColorMode = enum { mono, }; +pub const Background = enum { + dark, + light, + unknown, +}; + +pub const TerminalEnv = struct { + term: ?[]const u8 = null, + colorterm: ?[]const u8 = null, + no_color: ?[]const u8 = null, + mim_color: ?[]const u8 = null, + colorfgbg: ?[]const u8 = null, +}; + +pub const TerminalCapabilities = struct { + color_mode: ColorMode, + background: Background, +}; + pub const SgrKind = enum { fg, bg }; pub const AdaptedColor = union(enum) { @@ -98,6 +117,61 @@ pub fn rgb(r: u8, g: u8, b: u8) Color { return .{ .r = r, .g = g, .b = b }; } +pub fn detectTerminal(env: TerminalEnv) TerminalCapabilities { + return .{ + .color_mode = detectColorMode(env), + .background = detectBackground(env), + }; +} + +pub fn detectColorMode(env: TerminalEnv) ColorMode { + if (env.no_color != null) return .mono; + if (env.mim_color) |forced| { + if (std.ascii.eqlIgnoreCase(forced, "mono")) return .mono; + if (std.ascii.eqlIgnoreCase(forced, "16")) return .color16; + if (std.ascii.eqlIgnoreCase(forced, "256")) return .color256; + if (std.ascii.eqlIgnoreCase(forced, "truecolor") or std.ascii.eqlIgnoreCase(forced, "24bit")) return .truecolor; + } + if (env.term) |term| { + if (std.mem.eql(u8, term, "dumb")) return .mono; + } + if (env.colorterm) |colorterm| { + if (containsIgnoreCase(colorterm, "truecolor") or containsIgnoreCase(colorterm, "24bit")) return .truecolor; + } + if (env.term) |term| { + if (containsIgnoreCase(term, "truecolor") or containsIgnoreCase(term, "24bit")) return .truecolor; + if (containsIgnoreCase(term, "256color")) return .color256; + if (containsIgnoreCase(term, "color")) return .color16; + } + return .color16; +} + +pub fn detectBackground(env: TerminalEnv) Background { + const value = env.colorfgbg orelse return .unknown; + var fields = std.mem.splitScalar(u8, value, ';'); + var last: ?[]const u8 = null; + while (fields.next()) |field| last = field; + const bg = last orelse return .unknown; + const code = std.fmt.parseInt(u8, bg, 10) catch return .unknown; + return if (code >= 0 and code <= 6) .dark else .light; +} + +pub fn fromProcessEnv() TerminalCapabilities { + return detectTerminal(.{ + .term = std.posix.getenv("TERM"), + .colorterm = std.posix.getenv("COLORTERM"), + .no_color = std.posix.getenv("NO_COLOR"), + .mim_color = std.posix.getenv("MIM_COLOR"), + .colorfgbg = std.posix.getenv("COLORFGBG"), + }); +} + +pub fn hasAccessibleContrast(style: Style, fallback_bg: Color, minimum_ratio: f32) bool { + const fg = style.fg orelse return true; + const bg = style.bg orelse fallback_bg; + return contrastRatio(fg, bg) >= minimum_ratio; +} + pub fn adaptColor(color: Color, mode: ColorMode) AdaptedColor { return switch (mode) { .truecolor => .{ .rgb = color }, @@ -156,6 +230,16 @@ fn sgrBase(kind: SgrKind) u8 { }; } +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len == 0) return true; + if (needle.len > haystack.len) return false; + var i: usize = 0; + while (i + needle.len <= haystack.len) : (i += 1) { + if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return true; + } + return false; +} + fn nearest16(color: Color) u8 { return nearestInTable(color, ansi16[0..]); } @@ -291,3 +375,32 @@ test "regular: 256 and 16 color SGR use indexed form" { defer std.testing.allocator.free(rendered16); try std.testing.expect(std.mem.startsWith(u8, rendered16, "\x1b[48;5;")); } + +test "regular: terminal color-mode detection follows safe precedence" { + try std.testing.expectEqual(ColorMode.mono, detectColorMode(.{ .no_color = "1", .colorterm = "truecolor", .term = "xterm-256color" })); + try std.testing.expectEqual(ColorMode.mono, detectColorMode(.{ .term = "dumb" })); + try std.testing.expectEqual(ColorMode.truecolor, detectColorMode(.{ .colorterm = "truecolor", .term = "xterm-256color" })); + try std.testing.expectEqual(ColorMode.truecolor, detectColorMode(.{ .mim_color = "24bit", .term = "screen" })); + try std.testing.expectEqual(ColorMode.color256, detectColorMode(.{ .term = "xterm-256color" })); + try std.testing.expectEqual(ColorMode.color16, detectColorMode(.{ .term = "xterm-color" })); + try std.testing.expectEqual(ColorMode.color16, detectColorMode(.{})); +} + +test "regular: terminal background detection uses COLORFGBG final field" { + try std.testing.expectEqual(Background.dark, detectBackground(.{ .colorfgbg = "15;0" })); + try std.testing.expectEqual(Background.light, detectBackground(.{ .colorfgbg = "0;15" })); + try std.testing.expectEqual(Background.unknown, detectBackground(.{ .colorfgbg = "broken" })); + try std.testing.expectEqual(Background.unknown, detectBackground(.{})); +} + +test "regular: terminal capability detector combines color and background" { + const caps = detectTerminal(.{ .term = "xterm-256color", .colorfgbg = "15;0" }); + try std.testing.expectEqual(ColorMode.color256, caps.color_mode); + try std.testing.expectEqual(Background.dark, caps.background); +} + +test "regular: accessibility guardrail checks style contrast" { + try std.testing.expect(hasAccessibleContrast(newed_night.normal, newed_night.normal.bg.?, 7.0)); + try std.testing.expect(!hasAccessibleContrast(.{ .fg = rgb(30, 30, 35), .bg = rgb(25, 22, 33) }, newed_night.normal.bg.?, 4.5)); + try std.testing.expect(hasAccessibleContrast(.{ .bg = rgb(25, 22, 33) }, newed_night.normal.bg.?, 4.5)); +}