From ccffb9149d794967db2c4fde5faf405a422bbbc2 Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 16 Apr 2026 02:01:36 -0700 Subject: [PATCH] Colors... We'll need a new space. --- src/AppDelegate.swift | 4 +- src/Theme.swift | 33 +++++++- viewport/src/palette.rs | 35 ++++++++- viewport/src/syntax.rs | 161 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 221 insertions(+), 12 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index ae46d85..99e46c7 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -589,12 +589,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { let mode = ConfigManager.shared.themeMode let name: String switch mode { - case "dark": name = "mocha" + case "dark": name = "kicad" case "light": name = "latte" default: let appearance = NSApp.effectiveAppearance let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - name = isDark ? "mocha" : "latte" + name = isDark ? "kicad" : "latte" } viewport?.setTheme(name) } diff --git a/src/Theme.swift b/src/Theme.swift index 73f6ceb..09ed2ed 100644 --- a/src/Theme.swift +++ b/src/Theme.swift @@ -60,6 +60,35 @@ struct Theme { rosewater: NSColor(red: 0.961, green: 0.761, blue: 0.765, alpha: 1) ) + static let kicad = CatppuccinPalette( + base: NSColor(red: 0.090, green: 0.094, blue: 0.114, alpha: 1), + mantle: NSColor(red: 0.075, green: 0.078, blue: 0.102, alpha: 1), + crust: NSColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1), + surface0: NSColor(red: 0.102, green: 0.110, blue: 0.125, alpha: 1), + surface1: NSColor(red: 0.122, green: 0.126, blue: 0.141, alpha: 1), + surface2: NSColor(red: 0.133, green: 0.141, blue: 0.149, alpha: 1), + overlay0: NSColor(red: 0.361, green: 0.368, blue: 0.418, alpha: 1), + overlay1: NSColor(red: 0.449, green: 0.453, blue: 0.499, alpha: 1), + overlay2: NSColor(red: 0.548, green: 0.545, blue: 0.598, alpha: 1), + text: NSColor(red: 0.965, green: 0.954, blue: 0.969, alpha: 1), + subtext0: NSColor(red: 0.679, green: 0.668, blue: 0.725, alpha: 1), + subtext1: NSColor(red: 0.824, green: 0.813, blue: 0.852, alpha: 1), + red: NSColor(red: 0.914, green: 0.376, blue: 0.376, alpha: 1), + maroon: NSColor(red: 0.949, green: 0.416, blue: 0.584, alpha: 1), + peach: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), + yellow: NSColor(red: 0.988, green: 0.831, blue: 0.349, alpha: 1), + green: NSColor(red: 0.403, green: 0.972, blue: 0.534, alpha: 1), + teal: NSColor(red: 0.310, green: 1.000, blue: 0.882, alpha: 1), + sky: NSColor(red: 0.403, green: 0.813, blue: 0.972, alpha: 1), + sapphire: NSColor(red: 0.384, green: 0.635, blue: 0.949, alpha: 1), + blue: NSColor(red: 0.337, green: 0.475, blue: 0.988, alpha: 1), + lavender: NSColor(red: 1.000, green: 0.718, blue: 0.937, alpha: 1), + mauve: NSColor(red: 0.635, green: 0.282, blue: 0.980, alpha: 1), + pink: NSColor(red: 0.973, green: 0.345, blue: 0.718, alpha: 1), + flamingo: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), + rosewater: NSColor(red: 0.984, green: 0.639, blue: 0.757, alpha: 1) + ) + static let latte = CatppuccinPalette( base: NSColor(red: 0.937, green: 0.929, blue: 0.961, alpha: 1), mantle: NSColor(red: 0.906, green: 0.898, blue: 0.941, alpha: 1), @@ -92,12 +121,12 @@ struct Theme { static var current: CatppuccinPalette { let mode = ConfigManager.shared.themeMode switch mode { - case "dark": return mocha + case "dark": return kicad case "light": return latte default: let appearance = NSApp.effectiveAppearance let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return isDark ? mocha : latte + return isDark ? kicad : latte } } diff --git a/viewport/src/palette.rs b/viewport/src/palette.rs index a1096c6..88438ac 100644 --- a/viewport/src/palette.rs +++ b/viewport/src/palette.rs @@ -60,6 +60,38 @@ pub static MOCHA: Palette = Palette { crust: Color::from_rgb(0.067, 0.067, 0.106), }; +/// KiCad-inspired dark — near-black background, saturated accents, high +/// contrast. The signature KiCad schematic-editor feel: vivid greens, +/// bright cyans, punchy reds and yellows on a deep navy base. +pub static KICAD: Palette = Palette { + rosewater: Color::from_rgb(0.984, 0.639, 0.757), + flamingo: Color::from_rgb(0.965, 0.533, 0.404), + pink: Color::from_rgb(0.973, 0.345, 0.718), + mauve: Color::from_rgb(0.635, 0.282, 0.980), + red: Color::from_rgb(0.914, 0.376, 0.376), + maroon: Color::from_rgb(0.949, 0.416, 0.584), + peach: Color::from_rgb(0.965, 0.533, 0.404), + yellow: Color::from_rgb(0.988, 0.831, 0.349), + green: Color::from_rgb(0.403, 0.972, 0.534), + teal: Color::from_rgb(0.310, 1.000, 0.882), + sky: Color::from_rgb(0.403, 0.813, 0.972), + sapphire: Color::from_rgb(0.384, 0.635, 0.949), + blue: Color::from_rgb(0.337, 0.475, 0.988), + lavender: Color::from_rgb(1.000, 0.718, 0.937), + text: Color::from_rgb(0.965, 0.954, 0.969), + subtext1: Color::from_rgb(0.824, 0.813, 0.852), + subtext0: Color::from_rgb(0.679, 0.668, 0.725), + overlay2: Color::from_rgb(0.548, 0.545, 0.598), + overlay1: Color::from_rgb(0.449, 0.453, 0.499), + overlay0: Color::from_rgb(0.361, 0.368, 0.418), + surface2: Color::from_rgb(0.133, 0.141, 0.149), + surface1: Color::from_rgb(0.122, 0.126, 0.141), + surface0: Color::from_rgb(0.102, 0.110, 0.125), + base: Color::from_rgb(0.090, 0.094, 0.114), + mantle: Color::from_rgb(0.075, 0.078, 0.102), + crust: Color::from_rgb(0.059, 0.059, 0.059), +}; + pub static LATTE: Palette = Palette { rosewater: Color::from_rgb(0.863, 0.541, 0.471), flamingo: Color::from_rgb(0.867, 0.471, 0.471), @@ -105,7 +137,8 @@ pub fn is_dark() -> bool { pub fn set_theme(name: &str) { let (pal, dark) = match name { "latte" | "light" => (&LATTE, false), - _ => (&MOCHA, true), + "kicad" => (&KICAD, true), + _ => (&KICAD, true), }; CURRENT.with(|c| *c.borrow_mut() = pal); IS_DARK.with(|d| *d.borrow_mut() = dark); diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 47ebe07..53658b9 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::ops::Range; use iced_wgpu::core::text::highlighter; @@ -32,6 +33,15 @@ const COR_OPERATOR: u8 = 62; const COR_BRACKET: u8 = 63; const COR_TYPE_ANN: u8 = 64; +// Per-identifier rainbow. Each user-introduced name (let, fn, params, for var, +// math-form fn def) gets one of eight palette slots, picked with a stride +// that avoids adjacent colors landing on consecutive identifiers. Subsequent +// references resolve to the same slot so the name reads the same color +// throughout the document. +const USER_IDENT_BASE: u8 = 70; +const USER_IDENT_PALETTE_SIZE: u8 = 8; +const USER_IDENT_HOP: u32 = 3; + const MD_HEADING_MARKER: u8 = 26; const MD_H1: u8 = 27; const MD_H2: u8 = 28; @@ -92,6 +102,7 @@ pub struct SyntaxHighlighter { in_fenced_code: bool, current_line: usize, line_decors: Vec, + user_idents: HashMap, } impl SyntaxHighlighter { @@ -132,6 +143,87 @@ impl SyntaxHighlighter { self.in_fenced_code = false; self.current_line = 0; + + self.scan_user_idents(source); + } + + /// Walk the source, find every identifier introduction site (let, fn, + /// for, math-form fn def, params), and assign each unique name a slot + /// in the user-ident rainbow. Subsequent references in `highlight_cordial` + /// look the name up here. + fn scan_user_idents(&mut self, source: &str) { + self.user_idents.clear(); + let mut next_slot: u32 = 0; + + for line in source.split('\n') { + let trimmed = line.trim_start(); + let bytes = trimmed.as_bytes(); + + // `let IDENT...` + if let Some(rest) = trimmed.strip_prefix("let ") { + let mut i = 0; + let rb = rest.as_bytes(); + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); + } + while i < rb.len() && rb[i] == b' ' { i += 1; } + if i < rb.len() && rb[i] == b'(' { + extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot); + } + continue; + } + + // `fn IDENT(...)` + if let Some(rest) = trimmed.strip_prefix("fn ") { + let mut i = 0; + let rb = rest.as_bytes(); + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); + } + while i < rb.len() && rb[i] == b' ' { i += 1; } + if i < rb.len() && rb[i] == b'(' { + extract_paren_idents(&rest[i + 1..], &mut self.user_idents, &mut next_slot); + } + continue; + } + + // `for IDENT in ...` + if let Some(rest) = trimmed.strip_prefix("for ") { + let rb = rest.as_bytes(); + let mut i = 0; + while i < rb.len() && rb[i] == b' ' { i += 1; } + let name_start = i; + while i < rb.len() && (rb[i].is_ascii_alphanumeric() || rb[i] == b'_') { i += 1; } + if i > name_start { + assign_user_ident(&mut self.user_idents, &mut next_slot, &rest[name_start..i]); + } + continue; + } + + // `IDENT(...) = ...` math-form fn def, OR `IDENT = ...` assignment + let mut i = 0; + let name_start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } + if i > name_start { + let name = &trimmed[name_start..i]; + let mut j = i; + while j < bytes.len() && bytes[j] == b' ' { j += 1; } + if j < bytes.len() { + if bytes[j] == b'(' { + assign_user_ident(&mut self.user_idents, &mut next_slot, name); + extract_paren_idents(&trimmed[j + 1..], &mut self.user_idents, &mut next_slot); + } else if bytes[j] == b'=' && (j + 1 >= bytes.len() || bytes[j + 1] != b'=') { + assign_user_ident(&mut self.user_idents, &mut next_slot, name); + } + } + } + } } fn highlight_markdown(&self, line: &str) -> Vec<(Range, SyntaxHighlight)> { @@ -204,7 +296,50 @@ impl SyntaxHighlighter { /// spans. Idempotent, single-pass; each branch either consumes a whole /// token or advances one byte. Unknown bytes get no highlight (they fall /// through to the editor's default text color). -fn highlight_cordial(line: &str) -> Vec<(Range, SyntaxHighlight)> { +fn assign_user_ident(map: &mut HashMap, slot: &mut u32, name: &str) { + if name.is_empty() + || is_cordial_keyword(name) + || is_cordial_builtin(name) + || is_cordial_type_annotation(name) + || name == "pi" + || name == "where" + || name == "from" + || name == "solve" + || map.contains_key(name) + { + return; + } + let color_idx = ((*slot * USER_IDENT_HOP) % USER_IDENT_PALETTE_SIZE as u32) as u8; + map.insert(name.to_string(), color_idx); + *slot += 1; +} + +fn extract_paren_idents(s: &str, map: &mut HashMap, slot: &mut u32) { + let bytes = s.as_bytes(); + let mut i = 0; + let mut depth: i32 = 1; + while i < bytes.len() && depth > 0 { + match bytes[i] { + b'(' => { depth += 1; i += 1; } + b')' => { depth -= 1; i += 1; } + b':' => { + // Skip the type identifier that follows; type names belong + // to the type-annotation color, not the user-ident rainbow. + i += 1; + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; } + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } + } + c if c.is_ascii_alphabetic() || c == b'_' => { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } + assign_user_ident(map, slot, &s[start..i]); + } + _ => i += 1, + } + } +} + +fn highlight_cordial(line: &str, user_idents: &HashMap) -> Vec<(Range, SyntaxHighlight)> { let bytes = line.as_bytes(); let len = bytes.len(); let mut spans: Vec<(Range, SyntaxHighlight)> = Vec::new(); @@ -317,9 +452,7 @@ fn highlight_cordial(line: &str) -> Vec<(Range, SyntaxHighlight)> { continue; } - // Identifier → keyword / builtin / plain. Plain idents get no - // highlight so user-defined names stay in the default editor - // color — keeps the document from looking like confetti. + // Identifier → keyword / builtin / type-annotation / user-rainbow. if is_ident_byte(c) && !c.is_ascii_digit() { let start = i; while i < len && is_ident_byte(bytes[i]) { i += 1; } @@ -329,9 +462,9 @@ fn highlight_cordial(line: &str) -> Vec<(Range, SyntaxHighlight)> { } else if is_cordial_builtin(word) { spans.push((start..i, SyntaxHighlight { kind: COR_BUILTIN_FN })); } else if is_cordial_type_annotation(word) && last_token_is_colon(&spans) { - // Type annotation immediately after `:` in a `let x: T = …` - // reads the type name; give it the yellow/type color. spans.push((start..i, SyntaxHighlight { kind: COR_TYPE_ANN })); + } else if let Some(&slot) = user_idents.get(word) { + spans.push((start..i, SyntaxHighlight { kind: USER_IDENT_BASE + slot })); } continue; } @@ -682,6 +815,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { in_fenced_code: false, current_line: 0, line_decors: Vec::new(), + user_idents: HashMap::new(), }; h.rebuild(&settings.source); h @@ -740,7 +874,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { if ln < self.line_kinds.len() && matches!(self.line_kinds[ln], LineKind::Cordial | LineKind::Eval | LineKind::Comment) { - return highlight_cordial(line).into_iter(); + return highlight_cordial(line, &self.user_idents).into_iter(); } if ln >= self.line_offsets.len() { @@ -775,6 +909,19 @@ impl highlighter::Highlighter for SyntaxHighlighter { pub fn highlight_color(kind: u8) -> Color { let p = palette::current(); + if kind >= USER_IDENT_BASE && kind < USER_IDENT_BASE + USER_IDENT_PALETTE_SIZE { + return match kind - USER_IDENT_BASE { + 0 => p.red, + 1 => p.green, + 2 => p.peach, + 3 => p.blue, + 4 => p.mauve, + 5 => p.teal, + 6 => p.yellow, + 7 => p.pink, + _ => p.text, + }; + } match kind { 0 => p.mauve, 1 => p.blue,