diff --git a/crates/edit/src/bin/edit/draw_menubar.rs b/crates/edit/src/bin/edit/draw_menubar.rs index 401614786a1..d3ebdd668fb 100644 --- a/crates/edit/src/bin/edit/draw_menubar.rs +++ b/crates/edit/src/bin/edit/draw_menubar.rs @@ -7,7 +7,7 @@ use edit::tui::*; use stdext::arena_format; use crate::localization::*; -use crate::settings::Settings; +use crate::settings::{Settings, Theme}; use crate::state::*; pub fn draw_menubar(ctx: &mut Context, state: &mut State) { @@ -69,6 +69,11 @@ fn draw_menu_file(ctx: &mut Context, state: &mut State) { Err(err) => error_log_add(ctx, state, err), } } + if ctx.menubar_menu_button(loc(LocId::FileTheme), 'T', vk::NULL) { + // EN: Theme selection is an independent persistent setting. + // 中文:主題選擇是一項獨立且可保存的設定。 + state.wants_theme_picker = true; + } if state.documents.active().is_some() && ctx.menubar_menu_button(loc(LocId::FileClose), 'C', kbmod::CTRL | vk::W) { @@ -197,3 +202,40 @@ pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { state.wants_about = false; } } + +pub fn draw_dialog_theme(ctx: &mut Context, state: &mut State) { + // EN: Present the five persistent display themes in one independent modal. + // 中文:以獨立對話框提供五種可保存的畫面主題選擇。 + let mut selected = None; + + ctx.modal_begin("theme", loc(LocId::ThemeDialogTitle)); + { + ctx.list_begin("themes"); + ctx.inherit_focus(); + ctx.focus_on_first_present(); + ctx.attr_padding(Rect::three(1, 2, 1)); + { + for theme in Theme::ALL { + if ctx.list_item(theme == state.theme, theme.display_name()) + == ListSelection::Activated + { + selected = Some(theme); + } + } + } + ctx.list_end(); + } + let close = ctx.modal_end(); + + if let Some(theme) = selected { + state.theme = theme; + state.wants_theme_picker = false; + if let Err(err) = Settings::set_theme(theme) { + error_log_add(ctx, state, err); + } + ctx.needs_rerender(); + } else if close { + state.wants_theme_picker = false; + ctx.needs_rerender(); + } +} diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 27ae7fab6c0..2364cf9a329 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -76,6 +76,7 @@ fn run() -> apperr::Result<()> { if let Err(err) = Settings::reload() { state.add_error(err); } + state.theme = Settings::borrow().theme; if handle_args(&mut state)? { return Ok(()); @@ -337,6 +338,7 @@ fn print_version() { } fn draw(ctx: &mut Context, state: &mut State) { + configure_theme(ctx, state); draw_menubar(ctx, state); draw_editor(ctx, state); draw_statusbar(ctx, state); @@ -365,6 +367,9 @@ fn draw(ctx: &mut Context, state: &mut State) { if state.wants_go_to_file { draw_go_to_file(ctx, state); } + if state.wants_theme_picker { + draw_dialog_theme(ctx, state); + } if state.wants_about { draw_dialog_about(ctx, state); } @@ -414,6 +419,26 @@ fn draw(ctx: &mut Context, state: &mut State) { } } +fn configure_theme(ctx: &mut Context, state: &mut State) { + // EN: Apply editor, modal, and selected-text colors; DEFAULT follows the terminal palette. + // 中文:套用編輯區、對話框及反白文字色彩;DEFAULT 維持依終端配色調整。 + if let Some(colors) = state.theme.colors() { + ctx.attr_background_rgba(colors.background); + ctx.attr_foreground_rgba(colors.foreground); + ctx.set_floater_default_colors(colors.background, colors.foreground); + ctx.set_modal_default_colors(colors.background, colors.foreground); + ctx.set_selection_colors(Some((colors.selection_background, colors.selection_foreground))); + } else { + let floater_bg = ctx + .indexed_alpha(IndexedColor::Background, 2, 3) + .oklab_blend(ctx.indexed_alpha(IndexedColor::Foreground, 1, 3)); + let floater_fg = ctx.contrasted(floater_bg); + ctx.set_floater_default_colors(floater_bg, floater_fg); + ctx.set_modal_default_colors(floater_bg, floater_fg); + ctx.set_selection_colors(None); + } +} + fn draw_handle_wants_exit(_ctx: &mut Context, state: &mut State) { while let Some(doc) = state.documents.active() { if doc.buffer.borrow().is_dirty() { diff --git a/crates/edit/src/bin/edit/settings.rs b/crates/edit/src/bin/edit/settings.rs index e29b0970a9b..b3e7124a4d9 100644 --- a/crates/edit/src/bin/edit/settings.rs +++ b/crates/edit/src/bin/edit/settings.rs @@ -1,16 +1,110 @@ +use std::fmt::Write as _; use std::path::PathBuf; 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; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Theme { + #[default] + Default, + Mspwb, + Cia, + Mm14, + XtAtStyle, +} + +#[derive(Clone, Copy)] +pub struct ThemeColors { + pub background: StraightRgba, + pub foreground: StraightRgba, + pub selection_background: StraightRgba, + pub selection_foreground: StraightRgba, +} + +impl Theme { + // EN: These are the five user-selectable display themes. + // 中文:以下為使用者可以選擇的五種畫面主題。 + pub const ALL: [Theme; 5] = + [Theme::Default, Theme::Mspwb, Theme::Cia, Theme::Mm14, Theme::XtAtStyle]; + + pub const fn display_name(self) -> &'static str { + match self { + Theme::Default => "DEFAULT", + Theme::Mspwb => "MSPWB", + Theme::Cia => "CIA", + Theme::Mm14 => "MM14", + Theme::XtAtStyle => "XT/AT Style", + } + } + + const fn setting_name(self) -> &'static str { + self.display_name() + } + + fn from_setting_name(value: &str) -> Option { + // EN: Accept historical labels while always writing the current names. + // 中文:讀取時相容舊名稱,儲存時一律寫入目前名稱。 + if value.eq_ignore_ascii_case("DEFAULT") { + Some(Theme::Default) + } else if value.eq_ignore_ascii_case("MSPWB") || value.eq_ignore_ascii_case("Theme MSPWB") { + Some(Theme::Mspwb) + } else if value.eq_ignore_ascii_case("CIA") || value.eq_ignore_ascii_case("Theme CIA") { + Some(Theme::Cia) + } else if value.eq_ignore_ascii_case("MM14") || value.eq_ignore_ascii_case("Theme MM14") { + Some(Theme::Mm14) + } else if value.eq_ignore_ascii_case("XT/AT Style") + || value.eq_ignore_ascii_case("IBM XT/AT") + || value.eq_ignore_ascii_case("Theme IBM XT/AT") + { + Some(Theme::XtAtStyle) + } else { + None + } + } + + pub const fn colors(self) -> Option { + let rgba = StraightRgba::from_rgba; + match self { + Theme::Default => None, + Theme::Mspwb => Some(ThemeColors { + background: rgba(0x0000ffff), + foreground: rgba(0xffffffff), + selection_background: rgba(0x000000ff), + selection_foreground: rgba(0x00ff00ff), + }), + Theme::Cia => Some(ThemeColors { + background: rgba(0x0a2240ff), + foreground: rgba(0xffffffff), + selection_background: rgba(0x000000ff), + selection_foreground: rgba(0x00ff00ff), + }), + Theme::Mm14 => Some(ThemeColors { + background: rgba(0x000000ff), + foreground: rgba(0xe6cea7ff), + selection_background: rgba(0xe6cea7ff), + selection_foreground: rgba(0x000000ff), + }), + Theme::XtAtStyle => Some(ThemeColors { + background: rgba(0x000000ff), + foreground: rgba(0x00ff00ff), + selection_background: rgba(0x00ff00ff), + selection_foreground: rgba(0x000000ff), + }), + } + } +} + pub struct Settings { pub path: PathBuf, + pub theme: Theme, pub file_associations: Vec<(String, &'static Language)>, } @@ -22,13 +116,14 @@ impl Settings { /// Fills the given settings.json text buffer with some initial contents for convenience. pub fn bootstrap(tb: &mut TextBuffer) { tb.set_crlf(false); - tb.write_raw(b"{\n}\n"); + let contents = Self::borrow().to_json(); + tb.write_raw(contents.as_bytes()); tb.cursor_move_to_logical(Default::default()); tb.mark_as_clean(); } const fn new() -> Self { - Settings { path: PathBuf::new(), file_associations: Vec::new() } + Settings { path: PathBuf::new(), theme: Theme::Default, file_associations: Vec::new() } } pub fn borrow() -> Ref<'static, Settings> { @@ -65,6 +160,16 @@ impl Settings { return Err(apperr::Error::SettingsInvalid("Non-object root")); }; + if let Some(value) = root.get("theme") { + let Some(value) = value.as_str() else { + return Err(apperr::Error::SettingsInvalid("theme")); + }; + let Some(theme) = Theme::from_setting_name(value) else { + return Err(apperr::Error::SettingsInvalid("theme")); + }; + self.theme = theme; + } + if let Some(f) = root.get_object("files.associations") { for &(mut key, ref value) in f.iter() { if !key.contains('/') { @@ -84,6 +189,60 @@ impl Settings { Ok(()) } + + pub fn set_theme(theme: Theme) -> apperr::Result<()> { + let settings = &mut *SETTINGS.0.borrow_mut(); + settings.theme = theme; + settings.save() + } + + fn save(&self) -> apperr::Result<()> { + if self.path.as_os_str().is_empty() { + return Ok(()); + } + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, self.to_json())?; + Ok(()) + } + + fn to_json(&self) -> String { + // EN: Serialize generated settings with LF on every supported platform. + // 中文:自行序列化設定,確保所有支援平台皆固定使用 LF。 + let mut contents = String::from("{\n \"theme\": "); + write_json_string(&mut contents, self.theme.setting_name()); + contents.push_str(",\n \"files.associations\": {"); + for (index, (pattern, language)) in self.file_associations.iter().enumerate() { + contents.push_str(if index == 0 { "\n " } else { ",\n " }); + write_json_string(&mut contents, pattern); + contents.push_str(": "); + write_json_string(&mut contents, language.id); + } + if !self.file_associations.is_empty() { + contents.push_str("\n "); + } + contents.push_str("}\n}\n"); + contents + } +} + +fn write_json_string(output: &mut String, value: &str) { + output.push('"'); + for ch in value.chars() { + match ch { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + '\0'..='\u{1f}' => _ = write!(output, "\\u{:04x}", ch as u32), + _ => output.push(ch), + } + } + output.push('"'); } fn settings_json_path() -> Option { @@ -117,3 +276,35 @@ fn config_dir() -> Option { .map(|p| push(p, "msedit")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_names_and_colors_match_the_specification() { + for theme in Theme::ALL { + assert_eq!(Theme::from_setting_name(theme.setting_name()), Some(theme)); + assert!(!theme.display_name().starts_with("Theme ")); + } + let mspwb = Theme::Mspwb.colors().unwrap(); + assert_eq!(mspwb.background.to_rgba(), 0x0000ffff); + assert_eq!(mspwb.selection_foreground.to_rgba(), 0x00ff00ff); + let cia = Theme::Cia.colors().unwrap(); + assert_eq!(cia.background.to_rgba(), 0x0a2240ff); + let mm14 = Theme::Mm14.colors().unwrap(); + assert_eq!(mm14.foreground.to_rgba(), 0xe6cea7ff); + let xt_at = Theme::XtAtStyle.colors().unwrap(); + assert_eq!(xt_at.foreground.to_rgba(), 0x00ff00ff); + } + + #[test] + fn theme_setting_serializes_as_lf_json() { + let mut settings = Settings::new(); + settings.theme = Theme::Cia; + assert_eq!( + settings.to_json(), + "{\n \"theme\": \"CIA\",\n \"files.associations\": {}\n}\n" + ); + } +} diff --git a/crates/edit/src/bin/edit/state.rs b/crates/edit/src/bin/edit/state.rs index 13a1cefbbea..5e8bbe3a578 100644 --- a/crates/edit/src/bin/edit/state.rs +++ b/crates/edit/src/bin/edit/state.rs @@ -15,6 +15,7 @@ use edit::{buffer, icu}; use crate::apperr; use crate::documents::DocumentManager; use crate::localization::*; +use crate::settings::Theme; #[repr(transparent)] pub struct FormatApperr(apperr::Error); @@ -133,6 +134,7 @@ pub struct OscTitleFileStatus { pub struct State { pub menubar_color_bg: StraightRgba, pub menubar_color_fg: StraightRgba, + pub theme: Theme, pub documents: DocumentManager, @@ -166,6 +168,7 @@ pub struct State { pub wants_statusbar_focus: bool, pub wants_indentation_picker: bool, pub wants_go_to_file: bool, + pub wants_theme_picker: bool, pub wants_about: bool, pub wants_close: bool, pub wants_exit: bool, @@ -184,6 +187,7 @@ impl State { Ok(Self { menubar_color_bg: StraightRgba::zero(), menubar_color_fg: StraightRgba::zero(), + theme: Theme::Default, documents: Default::default(), @@ -216,6 +220,7 @@ impl State { wants_encoding_change: StateEncodingChange::None, wants_indentation_picker: false, wants_go_to_file: false, + wants_theme_picker: false, wants_about: false, wants_close: false, wants_exit: false, diff --git a/crates/edit/src/buffer/mod.rs b/crates/edit/src/buffer/mod.rs index 777501f774a..0e6053fbecf 100644 --- a/crates/edit/src/buffer/mod.rs +++ b/crates/edit/src/buffer/mod.rs @@ -1791,6 +1791,7 @@ impl TextBuffer { let line_number_width = self.margin_width.max(3) as usize - 3; let text_width = width - self.margin_width; let mut visual_pos_x_max = 0; + let mut selection_rects = Vec::new(); // Pick the cursor closer to the `origin.y`. let mut cursor = { @@ -1910,17 +1911,10 @@ impl TextBuffer { bottom: top + 1, }; - let mut bg = fb.indexed(IndexedColor::Foreground).oklab_blend(fb.indexed_alpha( - IndexedColor::BrightBlue, - 1, - 2, - )); - if !focused { - bg = bg.oklab_blend(fb.indexed_alpha(IndexedColor::Background, 1, 2)); - }; - let fg = fb.contrasted(bg); + let (bg, fg) = fb.selection_colors(focused); fb.blend_bg(rect, bg); fb.blend_fg(rect, fg); + selection_rects.push(rect); } // Nothing to do if the entire line is empty. @@ -2023,6 +2017,14 @@ impl TextBuffer { let logical_y_end = cursor.logical_pos.y + 1; self.render_apply_highlights(origin, destination, logical_y_beg..logical_y_end, fb); + // EN: Explicit selection colors take precedence over syntax highlighting. + // 中文:明確指定的反白色彩優先於語法醒目提示色彩。 + let (selection_bg, selection_fg) = fb.selection_colors(focused); + for rect in selection_rects { + fb.blend_bg(rect, selection_bg); + fb.blend_fg(rect, selection_fg); + } + // Colorize the margin that we wrote above. if self.margin_width > 0 { let margin = Rect { diff --git a/crates/edit/src/framebuffer.rs b/crates/edit/src/framebuffer.rs index 74562af98d0..391fea92765 100644 --- a/crates/edit/src/framebuffer.rs +++ b/crates/edit/src/framebuffer.rs @@ -116,6 +116,7 @@ pub struct Framebuffer { contrast_colors: [Cell<(StraightRgba, StraightRgba)>; CACHE_TABLE_SIZE], background_fill: StraightRgba, foreground_fill: StraightRgba, + selection_colors: Option<(StraightRgba, StraightRgba)>, } impl Framebuffer { @@ -134,6 +135,7 @@ impl Framebuffer { CACHE_TABLE_SIZE], background_fill: DEFAULT_THEME[IndexedColor::Background as usize], foreground_fill: DEFAULT_THEME[IndexedColor::Foreground as usize], + selection_colors: None, } } @@ -165,6 +167,28 @@ impl Framebuffer { } } + /// EN: Overrides selected-text colors; `None` restores the adaptive defaults. + /// 中文:覆寫反白文字色彩;`None` 恢復自動調整的預設色彩。 + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.selection_colors = colors; + } + + pub fn selection_colors(&self, focused: bool) -> (StraightRgba, StraightRgba) { + if let Some(colors) = self.selection_colors { + return colors; + } + + let mut bg = self.indexed(IndexedColor::Foreground).oklab_blend(self.indexed_alpha( + IndexedColor::BrightBlue, + 1, + 2, + )); + if !focused { + bg = bg.oklab_blend(self.indexed_alpha(IndexedColor::Background, 1, 2)); + } + (bg, self.contrasted(bg)) + } + /// Begins a new frame with the given `size`. pub fn flip(&mut self, size: Size) { if size != self.buffers[0].bg_bitmap.size { diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..45a8c455904 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -465,6 +465,12 @@ impl Tui { self.modal_default_fg = color; } + /// EN: Sets selected-text colors; `None` restores terminal-adaptive colors. + /// 中文:設定反白文字色彩;`None` 恢復依終端機調整的色彩。 + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.framebuffer.set_selection_colors(colors); + } + /// If the TUI is currently running animations, etc., /// this will return a timeout smaller than [`time::Duration::MAX`]. pub fn read_timeout(&mut self) -> time::Duration { @@ -1443,6 +1449,20 @@ impl<'a> Context<'a, '_> { self.tui.framebuffer.contrasted(color) } + pub fn set_floater_default_colors(&mut self, bg: StraightRgba, fg: StraightRgba) { + self.tui.set_floater_default_bg(bg); + self.tui.set_floater_default_fg(fg); + } + + pub fn set_modal_default_colors(&mut self, bg: StraightRgba, fg: StraightRgba) { + self.tui.set_modal_default_bg(bg); + self.tui.set_modal_default_fg(fg); + } + + pub fn set_selection_colors(&mut self, colors: Option<(StraightRgba, StraightRgba)>) { + self.tui.set_selection_colors(colors); + } + /// Returns the clipboard. pub fn clipboard_ref(&self) -> &Clipboard { &self.tui.clipboard diff --git a/i18n/edit.toml b/i18n/edit.toml index 11039636308..b1b61d8bd39 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -452,6 +452,11 @@ tr = "Tercihler" zh-hans = "设置" zh-hant = "設定" +[FileTheme] +en = "Theme…" +zh-hans = "主题…" +zh-hant = "主題…" + [FileClose] en = "Close File" ar = "إغلاق الملف" @@ -1289,6 +1294,11 @@ vi = "Giới thiệu" zh-hans = "关于" zh-hant = "關於" +[ThemeDialogTitle] +en = "Select Theme" +zh-hans = "选择主题" +zh-hant = "選擇主題" + [AboutDialogVersion] en = "Version: " ar = "الإصدار: "