diff --git a/crates/edit/src/bin/edit/documents.rs b/crates/edit/src/bin/edit/documents.rs index 1bbdde61afd..bf8abebb56d 100644 --- a/crates/edit/src/bin/edit/documents.rs +++ b/crates/edit/src/bin/edit/documents.rs @@ -285,6 +285,12 @@ impl DocumentManager { tb.set_insert_final_newline(!cfg!(windows)); // As mandated by POSIX. tb.set_margin_enabled(true); tb.set_line_highlight_enabled(true); + + // Apply theme colors + let settings = Settings::borrow(); + let theme = &settings.theme; + tb.set_line_number_color(theme.line_number); + tb.set_line_highlight_color(theme.line_highlight); } Ok(buffer) } diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 30ad149b20b..de5456d8fd4 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -20,7 +20,7 @@ use draw_editor::*; use draw_filepicker::*; use draw_menubar::*; use draw_statusbar::*; -use edit::framebuffer::{self, IndexedColor}; +use edit::framebuffer::{self, INDEXED_COLORS_COUNT, IndexedColor}; use edit::helpers::*; use edit::input::{self, kbmod, vk}; use edit::oklab::StraightRgba; @@ -558,6 +558,98 @@ impl Drop for RestoreModes { } } +/// Apply user-defined theme colors to the indexed_colors array. +/// Returns a boolean array indicating which colors were user-configured. +/// User-configured colors take priority over terminal-reported colors. +fn apply_user_theme( + indexed_colors: &mut [StraightRgba; INDEXED_COLORS_COUNT], +) -> [bool; INDEXED_COLORS_COUNT] { + use crate::settings::Settings; + use edit::framebuffer::IndexedColor; + + let settings = Settings::borrow(); + let theme = &settings.theme; + let mut user_configured = [false; INDEXED_COLORS_COUNT]; + + // Apply standard 16 colors + if let Some(c) = theme.black { + indexed_colors[IndexedColor::Black as usize] = c; + user_configured[IndexedColor::Black as usize] = true; + } + if let Some(c) = theme.red { + indexed_colors[IndexedColor::Red as usize] = c; + user_configured[IndexedColor::Red as usize] = true; + } + if let Some(c) = theme.green { + indexed_colors[IndexedColor::Green as usize] = c; + user_configured[IndexedColor::Green as usize] = true; + } + if let Some(c) = theme.yellow { + indexed_colors[IndexedColor::Yellow as usize] = c; + user_configured[IndexedColor::Yellow as usize] = true; + } + if let Some(c) = theme.blue { + indexed_colors[IndexedColor::Blue as usize] = c; + user_configured[IndexedColor::Blue as usize] = true; + } + if let Some(c) = theme.magenta { + indexed_colors[IndexedColor::Magenta as usize] = c; + user_configured[IndexedColor::Magenta as usize] = true; + } + if let Some(c) = theme.cyan { + indexed_colors[IndexedColor::Cyan as usize] = c; + user_configured[IndexedColor::Cyan as usize] = true; + } + if let Some(c) = theme.white { + indexed_colors[IndexedColor::White as usize] = c; + user_configured[IndexedColor::White as usize] = true; + } + if let Some(c) = theme.bright_black { + indexed_colors[IndexedColor::BrightBlack as usize] = c; + user_configured[IndexedColor::BrightBlack as usize] = true; + } + if let Some(c) = theme.bright_red { + indexed_colors[IndexedColor::BrightRed as usize] = c; + user_configured[IndexedColor::BrightRed as usize] = true; + } + if let Some(c) = theme.bright_green { + indexed_colors[IndexedColor::BrightGreen as usize] = c; + user_configured[IndexedColor::BrightGreen as usize] = true; + } + if let Some(c) = theme.bright_yellow { + indexed_colors[IndexedColor::BrightYellow as usize] = c; + user_configured[IndexedColor::BrightYellow as usize] = true; + } + if let Some(c) = theme.bright_blue { + indexed_colors[IndexedColor::BrightBlue as usize] = c; + user_configured[IndexedColor::BrightBlue as usize] = true; + } + if let Some(c) = theme.bright_magenta { + indexed_colors[IndexedColor::BrightMagenta as usize] = c; + user_configured[IndexedColor::BrightMagenta as usize] = true; + } + if let Some(c) = theme.bright_cyan { + indexed_colors[IndexedColor::BrightCyan as usize] = c; + user_configured[IndexedColor::BrightCyan as usize] = true; + } + if let Some(c) = theme.bright_white { + indexed_colors[IndexedColor::BrightWhite as usize] = c; + user_configured[IndexedColor::BrightWhite as usize] = true; + } + + // Apply special colors + if let Some(c) = theme.background { + indexed_colors[IndexedColor::Background as usize] = c; + user_configured[IndexedColor::Background as usize] = true; + } + if let Some(c) = theme.foreground { + indexed_colors[IndexedColor::Foreground as usize] = c; + user_configured[IndexedColor::Foreground as usize] = true; + } + + user_configured +} + fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) -> RestoreModes { sys::write_stdout(concat!( // 1049: Alternative Screen Buffer @@ -588,9 +680,12 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) let mut done = false; let mut osc_buffer = String::new(); let mut indexed_colors = framebuffer::DEFAULT_THEME; - let mut color_responses = 0; + let mut _color_responses = 0; let mut ambiguous_width = 1; + // Apply user-defined theme colors first (highest priority) + let user_configured = apply_user_theme(&mut indexed_colors); + while !done { let scratch = scratch_arena(None); @@ -622,19 +717,28 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) let mut splits = data.split_terminator(';'); - let color = match splits.next().unwrap_or("") { + let color_idx = match splits.next().unwrap_or("") { // The response is `4;;rgb://`. "4" => match splits.next().unwrap_or("").parse::() { - Ok(val) if val < 16 => &mut indexed_colors[val], + Ok(val) if val < 16 => val, _ => continue, }, // The response is `10;rgb://`. - "10" => &mut indexed_colors[IndexedColor::Foreground as usize], + "10" => IndexedColor::Foreground as usize, // The response is `11;rgb://`. - "11" => &mut indexed_colors[IndexedColor::Background as usize], + "11" => IndexedColor::Background as usize, _ => continue, }; + // Skip if this color was user-configured (user theme has priority) + if user_configured[color_idx] { + _color_responses += 1; + osc_buffer.clear(); + continue; + } + + let color = &mut indexed_colors[color_idx]; + let color_param = splits.next().unwrap_or(""); if !color_param.starts_with("rgb:") { continue; @@ -658,7 +762,7 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) } *color = StraightRgba::from_le(rgb | 0xff000000); - color_responses += 1; + _color_responses += 1; osc_buffer.clear(); } _ => {} @@ -671,9 +775,10 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) state.documents.reflow_all(); } - if color_responses == indexed_colors.len() { - tui.setup_indexed_colors(indexed_colors); - } + // Always apply colors: user-configured colors take priority, + // terminal-reported colors fill in the rest, + // and DEFAULT_THEME is the fallback. + tui.setup_indexed_colors(indexed_colors); RestoreModes } diff --git a/crates/edit/src/bin/edit/settings.rs b/crates/edit/src/bin/edit/settings.rs index e29b0970a9b..dcb2c2a72ab 100644 --- a/crates/edit/src/bin/edit/settings.rs +++ b/crates/edit/src/bin/edit/settings.rs @@ -4,14 +4,74 @@ use edit::buffer::TextBuffer; use edit::cell::{Ref, SemiRefCell}; use edit::json; use edit::lsh::{LANGUAGES, Language}; +use edit::oklab::StraightRgba; use stdext::arena::{read_to_string, scratch_arena}; use stdext::arena_format; use crate::apperr; +/// Theme configuration with user-defined colors. +/// All colors are in RRGGBBAA format (same as framebuffer). +#[derive(Clone, Copy, Debug)] +pub struct ThemeConfig { + // Standard 16 terminal colors + pub black: Option, + pub red: Option, + pub green: Option, + pub yellow: Option, + pub blue: Option, + pub magenta: Option, + pub cyan: Option, + pub white: Option, + pub bright_black: Option, + pub bright_red: Option, + pub bright_green: Option, + pub bright_yellow: Option, + pub bright_blue: Option, + pub bright_magenta: Option, + pub bright_cyan: Option, + pub bright_white: Option, + // Special colors + pub background: Option, + pub foreground: Option, + // UI colors + pub line_number: Option, + pub line_highlight: Option, +} + +impl ThemeConfig { + /// Create an empty theme config (all colors are None) + pub const fn empty() -> Self { + Self { + black: None, + red: None, + green: None, + yellow: None, + blue: None, + magenta: None, + cyan: None, + white: None, + bright_black: None, + bright_red: None, + bright_green: None, + bright_yellow: None, + bright_blue: None, + bright_magenta: None, + bright_cyan: None, + bright_white: None, + background: None, + foreground: None, + line_number: None, + line_highlight: None, + } + } + +} + pub struct Settings { pub path: PathBuf, pub file_associations: Vec<(String, &'static Language)>, + pub theme: ThemeConfig, } struct SettingsCell(SemiRefCell); @@ -28,7 +88,11 @@ impl Settings { } const fn new() -> Self { - Settings { path: PathBuf::new(), file_associations: Vec::new() } + Settings { + path: PathBuf::new(), + file_associations: Vec::new(), + theme: ThemeConfig::empty(), + } } pub fn borrow() -> Ref<'static, Settings> { @@ -82,10 +146,75 @@ impl Settings { } } + // Parse theme configuration + if let Some(theme_obj) = root.get_object("theme") { + self.theme = parse_theme_config(theme_obj)?; + } + Ok(()) } } +/// Parse a hex color string (with or without # prefix) into StraightRgba +/// Supports: RRGGBB, #RRGGBB, RRGGBBAA, #RRGGBBAA +/// Format matches the framebuffer's DEFAULT_THEME (RRGGBBAA in big-endian) +fn parse_hex_color(s: &str) -> Option { + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + + let val = u32::from_str_radix(s, 16).ok()?; + + // Convert to RRGGBBAA format (same as DEFAULT_THEME in framebuffer.rs) + let rgba = match s.len() { + 6 => StraightRgba::from_be(val << 8 | 0xFF), // RRGGBB -> RRGGBBFF + 8 => StraightRgba::from_be(val), // RRGGBBAA + _ => return None, + }; + + Some(rgba) +} + +/// Parse theme configuration from JSON object +fn parse_theme_config(obj: edit::json::Object) -> apperr::Result { + let mut theme = ThemeConfig::empty(); + + for &(key, ref value) in obj.iter() { + let Some(color_str) = value.as_str() else { + return Err(apperr::Error::SettingsInvalid("theme color value must be a string")); + }; + + let Some(color) = parse_hex_color(color_str) else { + return Err(apperr::Error::SettingsInvalid("invalid color format")); + }; + + match key { + "black" => theme.black = Some(color), + "red" => theme.red = Some(color), + "green" => theme.green = Some(color), + "yellow" => theme.yellow = Some(color), + "blue" => theme.blue = Some(color), + "magenta" => theme.magenta = Some(color), + "cyan" => theme.cyan = Some(color), + "white" => theme.white = Some(color), + "brightBlack" | "bright_black" => theme.bright_black = Some(color), + "brightRed" | "bright_red" => theme.bright_red = Some(color), + "brightGreen" | "bright_green" => theme.bright_green = Some(color), + "brightYellow" | "bright_yellow" => theme.bright_yellow = Some(color), + "brightBlue" | "bright_blue" => theme.bright_blue = Some(color), + "brightMagenta" | "bright_magenta" => theme.bright_magenta = Some(color), + "brightCyan" | "bright_cyan" => theme.bright_cyan = Some(color), + "brightWhite" | "bright_white" => theme.bright_white = Some(color), + "background" | "bg" => theme.background = Some(color), + "foreground" | "fg" => theme.foreground = Some(color), + "lineNumber" | "line_number" => theme.line_number = Some(color), + "lineHighlight" | "line_highlight" => theme.line_highlight = Some(color), + _ => {} // Unknown theme key, ignore + } + } + + Ok(theme) +} + fn settings_json_path() -> Option { let mut config_dir = config_dir()?; config_dir.push("settings.json"); diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index 4ec01f103a7..02644a61550 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -262,6 +262,8 @@ pub struct TextBuffer { tab_size: CoordType, indent_with_tabs: bool, line_highlight_enabled: bool, + line_number_color: Option, + line_highlight_color: Option, language: Option<&'static Language>, ruler: CoordType, encoding: &'static str, @@ -312,6 +314,8 @@ impl TextBuffer { tab_size: 4, indent_with_tabs: false, line_highlight_enabled: false, + line_number_color: None, + line_highlight_color: None, language: None, ruler: 0, encoding: "UTF-8", @@ -607,6 +611,16 @@ impl TextBuffer { self.line_highlight_enabled = enabled; } + /// Set the color for line numbers in the margin. + pub fn set_line_number_color(&mut self, color: Option) { + self.line_number_color = color; + } + + /// Set the background color for the current line highlight. + pub fn set_line_highlight_color(&mut self, color: Option) { + self.line_highlight_color = color; + } + pub fn language(&self) -> Option<&'static Language> { self.language } @@ -2023,7 +2037,9 @@ impl TextBuffer { right: destination.left + self.margin_width, bottom: destination.bottom, }; - fb.blend_fg(margin, StraightRgba::from_le(0x7f7f7f7f)); + let line_number_color = + self.line_number_color.unwrap_or_else(|| StraightRgba::from_le(0x7f7f7f7f)); + fb.blend_fg(margin, line_number_color); } if self.ruler > 0 { @@ -2064,6 +2080,9 @@ impl TextBuffer { fb.set_cursor(cursor, self.overtype); if self.line_highlight_enabled && selection_beg >= selection_end { + let highlight_color = self + .line_highlight_color + .unwrap_or_else(|| StraightRgba::from_le(0x7f7f7f7f)); fb.blend_bg( Rect { left: destination.left, @@ -2071,7 +2090,7 @@ impl TextBuffer { right: destination.right, bottom: cursor.y + 1, }, - StraightRgba::from_le(0x7f7f7f7f), + highlight_color, ); } } diff --git a/crates/edit/src/framebuffer.rs b/crates/edit/src/framebuffer.rs index f6640e344fd..11a5311526a 100644 --- a/crates/edit/src/framebuffer.rs +++ b/crates/edit/src/framebuffer.rs @@ -142,8 +142,8 @@ impl Framebuffer { /// successfully detect the light/dark mode of the terminal. pub fn set_indexed_colors(&mut self, colors: [StraightRgba; INDEXED_COLORS_COUNT]) { self.indexed_colors = colors; - self.background_fill = StraightRgba::zero(); - self.foreground_fill = StraightRgba::zero(); + self.background_fill = self.indexed_colors[IndexedColor::Background as usize]; + self.foreground_fill = self.indexed_colors[IndexedColor::Foreground as usize]; self.auto_colors = [ self.indexed_colors[IndexedColor::Black as usize],