Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/edit/src/bin/edit/documents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
125 changes: 115 additions & 10 deletions crates/edit/src/bin/edit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;<color>;rgb:<r>/<g>/<b>`.
"4" => match splits.next().unwrap_or("").parse::<usize>() {
Ok(val) if val < 16 => &mut indexed_colors[val],
Ok(val) if val < 16 => val,
_ => continue,
},
// The response is `10;rgb:<r>/<g>/<b>`.
"10" => &mut indexed_colors[IndexedColor::Foreground as usize],
"10" => IndexedColor::Foreground as usize,
// The response is `11;rgb:<r>/<g>/<b>`.
"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;
Expand All @@ -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();
}
_ => {}
Expand All @@ -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
}
Expand Down
131 changes: 130 additions & 1 deletion crates/edit/src/bin/edit/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StraightRgba>,
pub red: Option<StraightRgba>,
pub green: Option<StraightRgba>,
pub yellow: Option<StraightRgba>,
pub blue: Option<StraightRgba>,
pub magenta: Option<StraightRgba>,
pub cyan: Option<StraightRgba>,
pub white: Option<StraightRgba>,
pub bright_black: Option<StraightRgba>,
pub bright_red: Option<StraightRgba>,
pub bright_green: Option<StraightRgba>,
pub bright_yellow: Option<StraightRgba>,
pub bright_blue: Option<StraightRgba>,
pub bright_magenta: Option<StraightRgba>,
pub bright_cyan: Option<StraightRgba>,
pub bright_white: Option<StraightRgba>,
// Special colors
pub background: Option<StraightRgba>,
pub foreground: Option<StraightRgba>,
// UI colors
pub line_number: Option<StraightRgba>,
pub line_highlight: Option<StraightRgba>,
}

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<Settings>);
Expand All @@ -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> {
Expand Down Expand Up @@ -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<StraightRgba> {
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<ThemeConfig> {
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<PathBuf> {
let mut config_dir = config_dir()?;
config_dir.push("settings.json");
Expand Down
Loading