Detect terminal color capabilities

This commit is contained in:
slhx agent
2026-06-21 22:07:24 +02:00
parent 7f6d37d4a3
commit de3b5d436f
2 changed files with 115 additions and 0 deletions
+113
View File
@@ -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));
}