mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
364 lines
16 KiB
Rust
364 lines
16 KiB
Rust
//! Edit → Preferences (⌘K): Illustrator-style category list + fields, rendered from the engine's
|
|
//! preference table ([`PREF_SPECS`]). The dialog edits a working copy in `Dialog::fields`
|
|
//! (one field per preference key, so agents can `ui.dialog.set` any of them) and OK applies it
|
|
//! through `prefs.set`. Also: persistence of engine prefs inside `UiState` and the per-frame
|
|
//! application of the UI-side preferences (brightness, canvas colour, UI scaling, render threads).
|
|
|
|
use serde_json::{Map, Value, json};
|
|
use vectorcraft_engine::Prefs;
|
|
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, PrefKind};
|
|
|
|
use crate::state::Dialog;
|
|
use crate::theme::{self, Brightness, Tokens};
|
|
use crate::{VectorcraftApp, widgets};
|
|
|
|
/// UI-only fields shown in the dialog (backed by `UiState`, not engine prefs).
|
|
const UI_FIELDS: &[(&str, &str, &str)] = &[
|
|
("__smartGuides", "Smart Guides", "Smart Guides (View → Smart Guides)"),
|
|
("__snapToGrid", "Guides & Grid", "Snap to Grid"),
|
|
("__taskBar", "General", "Enable Contextual Task Bar"),
|
|
];
|
|
|
|
pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
|
|
let mut fields: Map<String, Value> = app.session.prefs.to_json().as_object().cloned().unwrap_or_default();
|
|
let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]);
|
|
fields.insert("__category".into(), json!(cat));
|
|
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
|
|
fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid));
|
|
fields.insert("__taskBar".into(), json!(app.ui.task_bar));
|
|
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
|
|
}
|
|
|
|
/// OK: validate and apply every preference in the dialog (nothing changes on error).
|
|
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
|
let values: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
|
app.run("prefs.set", json!({ "values": values }))?;
|
|
app.ui.view.smart_guides = d.fields.get("__smartGuides").and_then(Value::as_bool).unwrap_or(app.ui.view.smart_guides);
|
|
app.ui.view.snap_to_grid = d.fields.get("__snapToGrid").and_then(Value::as_bool).unwrap_or(app.ui.view.snap_to_grid);
|
|
app.ui.task_bar = d.fields.get("__taskBar").and_then(Value::as_bool).unwrap_or(app.ui.task_bar);
|
|
app.ui.dialog = None;
|
|
app.ui.engine_prefs = app.session.prefs.to_json();
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
/// Copy the engine prefs into `UiState` so the host persists them with the UI state.
|
|
pub fn snapshot(app: &mut VectorcraftApp) {
|
|
app.ui.engine_prefs = app.session.prefs.to_json();
|
|
}
|
|
|
|
/// Restore engine prefs from `UiState` after the host loaded saved UI state.
|
|
pub fn restore(app: &mut VectorcraftApp) {
|
|
let mut p: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap_or_default();
|
|
if app.ui.engine_prefs.is_null() {
|
|
// Older preference files: keep the saved brightness.
|
|
p.ui_brightness = app.ui.brightness.id().into();
|
|
}
|
|
app.session.apply_prefs(p);
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
struct Applied {
|
|
brightness: Brightness,
|
|
white_canvas: bool,
|
|
threads: i32,
|
|
}
|
|
|
|
/// Per frame: push UI-side preferences into egui / the renderer when they change.
|
|
pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|
let p = &app.session.prefs;
|
|
if let Some(b) = Brightness::parse(&p.ui_brightness)
|
|
&& b != app.ui.brightness
|
|
{
|
|
app.ui.brightness = b;
|
|
}
|
|
let want = Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads };
|
|
let id = egui::Id::new("dc-applied-prefs");
|
|
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
|
|
if prev != Some(want) {
|
|
theme::apply(ctx, want.brightness);
|
|
if want.white_canvas {
|
|
let mut t = Tokens::get(ctx);
|
|
t.pasteboard = egui::Color32::WHITE;
|
|
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
|
}
|
|
if prev.is_some_and(|pr| pr.threads != want.threads) {
|
|
vectorcraft_render::set_default_threads(u16::try_from(want.threads).ok());
|
|
app.canvas.renderer.threads = vectorcraft_render::default_threads();
|
|
app.canvas.worker = None;
|
|
app.canvas.worker_started = false;
|
|
}
|
|
app.canvas.key = None;
|
|
ctx.data_mut(|d| d.insert_temp(id, Some(want)));
|
|
}
|
|
let z = app.session.prefs.ui_scaling.clamp(0.75, 2.0) as f32;
|
|
if (ctx.zoom_factor() - z).abs() > 1e-3 {
|
|
ctx.set_zoom_factor(z);
|
|
}
|
|
}
|
|
|
|
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|
let Some(mut d) = app.ui.dialog.clone() else { return };
|
|
let t = Tokens::get(ctx);
|
|
let (mut ok, mut cancel, mut reset) = (false, false, false);
|
|
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
|
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
|
});
|
|
let cat = d.str("__category");
|
|
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
|
|
egui::Window::new("Preferences")
|
|
.id(egui::Id::new("dialog-preferences"))
|
|
.order(egui::Order::Foreground)
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.title_bar(false)
|
|
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
|
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
|
.show(ctx, |ui| {
|
|
ui.set_width(760.0);
|
|
ui.label(egui::RichText::new("Preferences").font(theme::semibold(16.0)).color(t.text));
|
|
ui.add_space(12.0);
|
|
ui.horizontal_top(|ui| {
|
|
// Category list.
|
|
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(
|
|
ui,
|
|
|ui| {
|
|
ui.set_width(196.0);
|
|
ui.set_min_height(430.0);
|
|
ui.vertical(|ui| {
|
|
ui.spacing_mut().item_spacing.y = 1.0;
|
|
for c in PREF_CATEGORIES {
|
|
let sel = *c == cat;
|
|
let text = egui::RichText::new(*c).size(12.5).color(if sel { t.text_strong } else { t.text });
|
|
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
|
|
if ui.add(b).clicked() {
|
|
d.fields.insert("__category".into(), json!(c));
|
|
}
|
|
}
|
|
});
|
|
},
|
|
);
|
|
ui.add_space(14.0);
|
|
// Fields.
|
|
ui.vertical(|ui| {
|
|
ui.set_width(530.0);
|
|
ui.label(egui::RichText::new(cat).font(theme::semibold(14.0)).color(t.text_strong));
|
|
ui.add_space(8.0);
|
|
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
|
category_fields(ui, &mut d, cat);
|
|
});
|
|
});
|
|
});
|
|
ui.add_space(14.0);
|
|
ui.horizontal(|ui| {
|
|
if widgets::secondary_button(ui, "Reset Preferences")
|
|
.on_hover_text("Restore every preference to its default (applied on OK)")
|
|
.clicked()
|
|
{
|
|
reset = true;
|
|
}
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if widgets::primary_button(ui, "OK").clicked() {
|
|
ok = true;
|
|
}
|
|
ui.add_space(8.0);
|
|
if widgets::secondary_button(ui, "Cancel").clicked() {
|
|
cancel = true;
|
|
}
|
|
ui.add_space(16.0);
|
|
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
|
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new("Next")).clicked() {
|
|
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
|
}
|
|
if ui.add_enabled(i > 0, egui::Button::new("Previous")).clicked() {
|
|
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
if reset {
|
|
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
|
|
d.fields.insert(k, v);
|
|
}
|
|
}
|
|
app.ui.dialog = Some(d);
|
|
if cancel {
|
|
app.ui.dialog = None;
|
|
} else if ok && let Err(e) = confirm(app) {
|
|
app.status(e);
|
|
}
|
|
}
|
|
|
|
fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let mut section = "";
|
|
let mut first = true;
|
|
let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect();
|
|
for (key, c, label) in UI_FIELDS {
|
|
if *c == cat && cat != "Smart Guides" {
|
|
bool_row(ui, d, key, label);
|
|
}
|
|
}
|
|
if cat == "Smart Guides" {
|
|
bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2);
|
|
ui.add_space(4.0);
|
|
}
|
|
for sp in specs {
|
|
if sp.section != section {
|
|
section = sp.section;
|
|
if !section.is_empty() {
|
|
if !first {
|
|
ui.add_space(8.0);
|
|
}
|
|
ui.label(egui::RichText::new(section).font(theme::semibold(12.5)).color(t.text_strong));
|
|
ui.add_space(2.0);
|
|
}
|
|
}
|
|
first = false;
|
|
let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null);
|
|
match sp.kind {
|
|
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
|
PrefKind::Num { min, max, unit } => {
|
|
labeled(ui, sp.label, |ui| {
|
|
let mut x = v.as_f64().unwrap_or(min);
|
|
let r = if sp.key == "uiScaling" {
|
|
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text("Smaller ↔ Larger"))
|
|
} else {
|
|
let speed = if max - min > 100.0 { 0.5 } else { 0.05 };
|
|
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(speed).max_decimals(3).suffix(format!(" {unit}")))
|
|
};
|
|
if r.changed() {
|
|
d.fields.insert(sp.key.into(), json!(x));
|
|
}
|
|
});
|
|
}
|
|
PrefKind::Int { min, max } => {
|
|
labeled(ui, sp.label, |ui| {
|
|
let mut x = v.as_i64().unwrap_or(min);
|
|
let r = if sp.key == "anchorSize" {
|
|
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text("Size"))
|
|
} else {
|
|
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(0.2))
|
|
};
|
|
if r.changed() {
|
|
d.fields.insert(sp.key.into(), json!(x));
|
|
}
|
|
});
|
|
}
|
|
PrefKind::Choice(opts) => {
|
|
labeled(ui, sp.label, |ui| {
|
|
let cur = v.as_str().unwrap_or("");
|
|
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
|
|
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
|
|
if let Some(i) = widgets::dropdown(ui, sp.key, cur_label, &labels, 260.0) {
|
|
d.fields.insert(sp.key.into(), json!(opts[i].0));
|
|
}
|
|
});
|
|
}
|
|
PrefKind::Color => {
|
|
labeled(ui, sp.label, |ui| {
|
|
let hex = v.as_str().unwrap_or("#000000");
|
|
let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
|
|
let mut rgb = [c[0], c[1], c[2]];
|
|
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
|
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
|
}
|
|
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
|
|
});
|
|
}
|
|
PrefKind::Text => {
|
|
labeled(ui, sp.label, |ui| {
|
|
let mut s = v.as_str().unwrap_or("").to_string();
|
|
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)).changed() {
|
|
d.fields.insert(sp.key.into(), json!(s));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
|
let mut b = d.bool(key);
|
|
if ui.checkbox(&mut b, label).changed() {
|
|
d.fields.insert(key.into(), json!(b));
|
|
}
|
|
}
|
|
|
|
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.horizontal(|ui| {
|
|
ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
|
ui.set_min_width(210.0);
|
|
ui.add(egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text)).truncate());
|
|
});
|
|
add(ui);
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Services;
|
|
use vectorcraft_engine::Session;
|
|
|
|
fn app() -> VectorcraftApp {
|
|
VectorcraftApp::new(Session::new(), Services::default())
|
|
}
|
|
|
|
#[test]
|
|
fn dialog_ok_applies_prefs_and_ui_fields() {
|
|
let mut a = app();
|
|
open(&mut a, Some("general"));
|
|
let d = a.ui.dialog.as_mut().unwrap();
|
|
assert_eq!(d.str("__category"), "General");
|
|
d.fields.insert("keyboardIncrement".into(), json!(4));
|
|
d.fields.insert("uiBrightness".into(), json!("light"));
|
|
d.fields.insert("__smartGuides".into(), json!(false));
|
|
confirm(&mut a).unwrap();
|
|
assert!(a.ui.dialog.is_none());
|
|
assert_eq!(a.session.prefs.keyboard_increment, 4.0);
|
|
assert_eq!(a.session.prefs.ui_brightness, "light");
|
|
assert!(!a.ui.view.smart_guides);
|
|
assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0));
|
|
}
|
|
|
|
#[test]
|
|
fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() {
|
|
let mut a = app();
|
|
open(&mut a, None);
|
|
a.ui.dialog.as_mut().unwrap().fields.insert("anchorSize".into(), json!(42));
|
|
assert!(confirm(&mut a).is_err());
|
|
assert!(a.ui.dialog.is_some());
|
|
assert_eq!(a.session.prefs, Prefs::default());
|
|
}
|
|
|
|
#[test]
|
|
fn prefs_persist_through_ui_state() {
|
|
let mut a = app();
|
|
a.run("prefs.set", json!({"key": "gridlineEvery", "value": 50})).unwrap();
|
|
snapshot(&mut a);
|
|
let saved = serde_json::to_vec(&a.ui).unwrap();
|
|
let mut b = app();
|
|
b.ui = serde_json::from_slice::<crate::UiState>(&saved).unwrap().sanitized();
|
|
restore(&mut b);
|
|
assert_eq!(b.session.prefs.gridline_every, 50.0);
|
|
}
|
|
|
|
#[test]
|
|
fn old_pref_files_keep_their_brightness() {
|
|
let mut a = app();
|
|
a.ui.brightness = Brightness::Light;
|
|
a.ui.engine_prefs = Value::Null;
|
|
restore(&mut a);
|
|
assert_eq!(a.session.prefs.ui_brightness, "light");
|
|
}
|
|
|
|
#[test]
|
|
fn every_category_renders_fields() {
|
|
for c in PREF_CATEGORIES {
|
|
assert!(PREF_SPECS.iter().any(|s| s.category == *c));
|
|
}
|
|
}
|
|
}
|