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
44 changes: 43 additions & 1 deletion crates/edit/src/bin/edit/draw_menubar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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();
}
}
25 changes: 25 additions & 0 deletions crates/edit/src/bin/edit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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() {
Expand Down
195 changes: 193 additions & 2 deletions crates/edit/src/bin/edit/settings.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
// 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<ThemeColors> {
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)>,
}

Expand All @@ -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> {
Expand Down Expand Up @@ -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('/') {
Expand All @@ -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<PathBuf> {
Expand Down Expand Up @@ -117,3 +276,35 @@ fn config_dir() -> Option<PathBuf> {
.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"
);
}
}
5 changes: 5 additions & 0 deletions crates/edit/src/bin/edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand All @@ -184,6 +187,7 @@ impl State {
Ok(Self {
menubar_color_bg: StraightRgba::zero(),
menubar_color_fg: StraightRgba::zero(),
theme: Theme::Default,

documents: Default::default(),

Expand Down Expand Up @@ -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,
Expand Down
Loading