From 5652845d2e0cca07a4e33eafa3590ff62e048414 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 21:31:20 +0200 Subject: [PATCH] Add newed night theme model --- src/main.zig | 1 + src/theme.zig | 293 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/theme.zig diff --git a/src/main.zig b/src/main.zig index f89aa2f..0f85cd8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,7 @@ const socket = @import("socket.zig"); const symbol = @import("symbol.zig"); const syntax = @import("syntax.zig"); const terminal_debug = @import("terminal_debug.zig"); +const theme = @import("theme.zig"); const tui = @import("tui.zig"); pub const version = "0.1.0-dev"; diff --git a/src/theme.zig b/src/theme.zig new file mode 100644 index 0000000..8ace9a4 --- /dev/null +++ b/src/theme.zig @@ -0,0 +1,293 @@ +const std = @import("std"); + +/// Source-patched from newed night: +/// - /opt/repositories/newed/newed-theme/src/default_theme.rs +/// - /opt/repositories/newed/newed-theme/src/adapt.rs +/// +/// This is intentionally a tiny local model, not a runtime theme/config system. +pub const Color = struct { + r: u8, + g: u8, + b: u8, +}; + +pub const Token = enum { + normal, + muted, + accent, + selection, + panel_bg, + panel_border, + cursor, + diagnostic_error, + diagnostic_warning, + diagnostic_info, + diagnostic_hint, +}; + +pub const Style = struct { + fg: ?Color = null, + bg: ?Color = null, + bold: bool = false, + italic: bool = false, +}; + +pub const ColorMode = enum { + truecolor, + color256, + color16, + mono, +}; + +pub const SgrKind = enum { fg, bg }; + +pub const AdaptedColor = union(enum) { + rgb: Color, + indexed: u8, + reset, +}; + +pub const Palette = struct { + normal: Style, + muted: Style, + accent: Style, + selection: Style, + panel_bg: Style, + panel_border: Style, + cursor: Style, + diagnostic_error: Style, + diagnostic_warning: Style, + diagnostic_info: Style, + diagnostic_hint: Style, + + pub fn style(self: Palette, token: Token) Style { + return switch (token) { + .normal => self.normal, + .muted => self.muted, + .accent => self.accent, + .selection => self.selection, + .panel_bg => self.panel_bg, + .panel_border => self.panel_border, + .cursor => self.cursor, + .diagnostic_error => self.diagnostic_error, + .diagnostic_warning => self.diagnostic_warning, + .diagnostic_info => self.diagnostic_info, + .diagnostic_hint => self.diagnostic_hint, + }; + } +}; + +// RGB approximations of the named OKLCH anchors in newed default_theme.rs. +// The source anchors remain semantic: sumi-ink base, moonlight foreground, +// beni/coral accent, cool water selection, and distinct diagnostics. +pub const newed_night = Palette{ + .normal = .{ .fg = rgb(230, 230, 230), .bg = rgb(25, 22, 33) }, + .muted = .{ .fg = rgb(103, 93, 122), .bg = rgb(25, 22, 33) }, + .accent = .{ .fg = rgb(235, 119, 84), .bold = true }, + .selection = .{ .bg = rgb(55, 65, 92) }, + .panel_bg = .{ .fg = rgb(230, 230, 230), .bg = rgb(20, 18, 26) }, + .panel_border = .{ .fg = rgb(72, 67, 84), .bg = rgb(25, 22, 33) }, + .cursor = .{ .fg = rgb(25, 22, 33), .bg = rgb(230, 230, 230) }, + .diagnostic_error = .{ .fg = rgb(238, 84, 88), .bold = true }, + .diagnostic_warning = .{ .fg = rgb(231, 168, 78), .bold = true }, + .diagnostic_info = .{ .fg = rgb(121, 155, 224) }, + .diagnostic_hint = .{ .fg = rgb(116, 196, 171) }, +}; + +pub fn rgb(r: u8, g: u8, b: u8) Color { + return .{ .r = r, .g = g, .b = b }; +} + +pub fn adaptColor(color: Color, mode: ColorMode) AdaptedColor { + return switch (mode) { + .truecolor => .{ .rgb = color }, + .color256 => .{ .indexed = nearest256(color) }, + .color16 => .{ .indexed = nearest16(color) }, + .mono => .reset, + }; +} + +pub fn appendStyleSgr(writer: *std.Io.Writer, style: Style, mode: ColorMode) !void { + try writer.writeAll("\x1b[0m"); + if (mode == .mono) { + if (style.bold) try writer.writeAll("\x1b[1m"); + if (style.italic) try writer.writeAll("\x1b[3m"); + return; + } + if (style.bold) try writer.writeAll("\x1b[1m"); + if (style.italic) try writer.writeAll("\x1b[3m"); + if (style.fg) |fg| try appendColorSgr(writer, .fg, fg, mode); + if (style.bg) |bg| try appendColorSgr(writer, .bg, bg, mode); +} + +pub fn appendColorSgr(writer: *std.Io.Writer, kind: SgrKind, color: Color, mode: ColorMode) !void { + switch (adaptColor(color, mode)) { + .rgb => |c| try writer.print("\x1b[{d};2;{d};{d};{d}m", .{ sgrBase(kind), c.r, c.g, c.b }), + .indexed => |index| try writer.print("\x1b[{d};5;{d}m", .{ sgrBase(kind), index }), + .reset => {}, + } +} + +pub fn resetSgr() []const u8 { + return "\x1b[0m"; +} + +pub fn wrap(allocator: std.mem.Allocator, text: []const u8, style: Style, mode: ColorMode) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + try appendStyleSgr(&out.writer, style, mode); + try out.writer.writeAll(text); + try out.writer.writeAll(resetSgr()); + return out.toOwnedSlice(); +} + +pub fn contrastRatio(foreground: Color, background: Color) f32 { + const l1 = relativeLuminance(foreground); + const l2 = relativeLuminance(background); + const lighter = @max(l1, l2); + const darker = @min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +fn sgrBase(kind: SgrKind) u8 { + return switch (kind) { + .fg => 38, + .bg => 48, + }; +} + +fn nearest16(color: Color) u8 { + return nearestInTable(color, ansi16[0..]); +} + +fn nearest256(color: Color) u8 { + var best_index: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + var i: usize = 0; + while (i < 256) : (i += 1) { + const candidate = xterm256(@intCast(i)); + const distance = distanceSquared(color, candidate); + if (distance < best_distance) { + best_distance = distance; + best_index = @intCast(i); + } + } + return best_index; +} + +fn nearestInTable(color: Color, table: []const Color) u8 { + var best_index: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + for (table, 0..) |candidate, index| { + const distance = distanceSquared(color, candidate); + if (distance < best_distance) { + best_distance = distance; + best_index = @intCast(index); + } + } + return best_index; +} + +fn distanceSquared(a: Color, b: Color) u32 { + const dr: i32 = @as(i32, a.r) - @as(i32, b.r); + const dg: i32 = @as(i32, a.g) - @as(i32, b.g); + const db: i32 = @as(i32, a.b) - @as(i32, b.b); + return @intCast(dr * dr + dg * dg + db * db); +} + +fn xterm256(index: u8) Color { + if (index < 16) return ansi16[index]; + if (index >= 232) { + const level: u8 = 8 + 10 * (index - 232); + return rgb(level, level, level); + } + const cube = index - 16; + const r = cube / 36; + const g = (cube % 36) / 6; + const b = cube % 6; + return rgb(cubeLevel(r), cubeLevel(g), cubeLevel(b)); +} + +fn cubeLevel(value: u8) u8 { + return if (value == 0) 0 else 55 + value * 40; +} + +fn relativeLuminance(color: Color) f32 { + const r = linearize(@as(f32, @floatFromInt(color.r)) / 255.0); + const g = linearize(@as(f32, @floatFromInt(color.g)) / 255.0); + const b = linearize(@as(f32, @floatFromInt(color.b)) / 255.0); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +fn linearize(channel: f32) f32 { + if (channel <= 0.03928) return channel / 12.92; + return std.math.pow(f32, (channel + 0.055) / 1.055, 2.4); +} + +const ansi16 = [_]Color{ + rgb(0, 0, 0), + rgb(128, 0, 0), + rgb(0, 128, 0), + rgb(128, 128, 0), + rgb(0, 0, 128), + rgb(128, 0, 128), + rgb(0, 128, 128), + rgb(192, 192, 192), + rgb(128, 128, 128), + rgb(255, 0, 0), + rgb(0, 255, 0), + rgb(255, 255, 0), + rgb(0, 0, 255), + rgb(255, 0, 255), + rgb(0, 255, 255), + rgb(255, 255, 255), +}; + +test "regular: default palette exposes semantic newed night tokens" { + try std.testing.expectEqual(rgb(25, 22, 33), newed_night.normal.bg.?); + try std.testing.expectEqual(rgb(230, 230, 230), newed_night.normal.fg.?); + try std.testing.expectEqual(rgb(235, 119, 84), newed_night.style(.accent).fg.?); + try std.testing.expect(contrastRatio(newed_night.normal.fg.?, newed_night.normal.bg.?) > 10.0); +} + +test "regular: color adaptation is deterministic across terminal modes" { + const coral = newed_night.accent.fg.?; + try std.testing.expectEqual(AdaptedColor{ .rgb = coral }, adaptColor(coral, .truecolor)); + try std.testing.expectEqual(AdaptedColor{ .indexed = nearest256(coral) }, adaptColor(coral, .color256)); + try std.testing.expectEqual(AdaptedColor{ .indexed = nearest16(coral) }, adaptColor(coral, .color16)); + try std.testing.expectEqual(AdaptedColor.reset, adaptColor(coral, .mono)); +} + +test "regular: SGR output resets and does not leak styles" { + const rendered = try wrap(std.testing.allocator, "kw", newed_night.accent, .truecolor); + defer std.testing.allocator.free(rendered); + try std.testing.expect(std.mem.startsWith(u8, rendered, resetSgr())); + try std.testing.expect(std.mem.indexOf(u8, rendered, "\x1b[1m") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "\x1b[38;2;235;119;84m") != null); + try std.testing.expect(std.mem.endsWith(u8, rendered, resetSgr())); +} + +test "regular: mono preserves emphasis without color" { + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try appendStyleSgr(&out.writer, newed_night.accent, .mono); + const rendered = try out.toOwnedSlice(); + defer std.testing.allocator.free(rendered); + try std.testing.expectEqualStrings("\x1b[0m\x1b[1m", rendered); +} + +test "regular: 256 and 16 color SGR use indexed form" { + var out256: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out256.deinit(); + try appendColorSgr(&out256.writer, .fg, newed_night.diagnostic_warning.fg.?, .color256); + const rendered256 = try out256.toOwnedSlice(); + defer std.testing.allocator.free(rendered256); + try std.testing.expect(std.mem.startsWith(u8, rendered256, "\x1b[38;5;")); + + var out16: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out16.deinit(); + try appendColorSgr(&out16.writer, .bg, newed_night.selection.bg.?, .color16); + const rendered16 = try out16.toOwnedSlice(); + defer std.testing.allocator.free(rendered16); + try std.testing.expect(std.mem.startsWith(u8, rendered16, "\x1b[48;5;")); +}