From b0f9773205f11dfb3ce1cce3041d307feb4d8208 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Tue, 6 Oct 2026 04:57:56 +1100 Subject: [PATCH] Snap: view commands and guide preferences --- crates/ui-egui/src/dialogs.rs | 27 ++++++++++ crates/ui-egui/src/i18n.rs | 17 +++++++ crates/ui-egui/src/lib.rs | 66 ++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 17 +++++++ crates/ui-egui/src/panels/properties.rs | 4 +- 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1c95dc763..156e64b56 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -641,6 +641,18 @@ fn preferences(ui: &mut egui::Ui, d: &mut Dialog) { text_field(ui, d, "pasteboard.v", 80.0); ui.end_row(); }); + ui.add_space(6.0); + check(ui, d, "snap.alignEdges", "Align to Object Edges"); + check(ui, d, "snap.alignCenters", "Align to Object Centers"); + check(ui, d, "snap.dimensions", "Smart Dimensions"); + check(ui, d, "snap.spacing", "Smart Spacing"); + ui.horizontal(|ui| { + ui.label("Snap to Zone"); + let mut zone = d.n("snap.zone").filter(|z| z.is_finite()).unwrap_or(4.0); + if ui.add(egui::DragValue::new(&mut zone).speed(0.1)).changed() { + d.fields.insert("snap.zone".into(), json!(zone)); + } + }); } "display" => { ui.label(egui::RichText::new("Options").font(semibold(12.0))); @@ -1422,6 +1434,21 @@ pub fn confirm(app: &mut DesignApp) -> Result { if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; } + if d.fields.contains_key("snap.alignEdges") { + app.ui.align_edges = d.b("snap.alignEdges"); + } + if d.fields.contains_key("snap.alignCenters") { + app.ui.align_centers = d.b("snap.alignCenters"); + } + if d.fields.contains_key("snap.dimensions") { + app.ui.smart_dimensions = d.b("snap.dimensions"); + } + if d.fields.contains_key("snap.spacing") { + app.ui.smart_spacing = d.b("snap.spacing"); + } + if let Some(zone) = d.n("snap.zone").filter(|z| z.is_finite()) { + app.ui.snap_zone = if zone < 0.0 { 0.0 } else { zone }; + } if !d.fields.contains_key("horizontalUnits") { return Ok(Value::Null); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2191f5a39..90edb8276 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -101,6 +101,23 @@ const TABLE: &[(&str, [&str; 4])] = &[ ("Screen Mode", ["Bildschirmmodus", "Mode d'affichage", "Modo de pantalla", "スクリーンモード"]), ("Grids & Guides", ["Raster und Hilfslinien", "Grilles et repères", "Cuadrículas y guías", "グリッドとガイド"]), ("Show Rulers", ["Lineale einblenden", "Afficher les règles", "Mostrar reglas", "定規を表示"]), + ("Snap to Guides", ["An Hilfslinien ausrichten", "Coller aux repères", "Ajustar a las guías", "ガイドにスナップ"]), + ( + "Snap to Document Grid", + ["Am Dokumentraster ausrichten", "Coller à la grille du document", "Ajustar a la cuadrícula del documento", "ドキュメントグリッドにスナップ"], + ), + ("Smart Guides", ["Smarte Hilfslinien", "Repères intelligents", "Guías inteligentes", "スマートガイド"]), + ( + "Align to Object Edges", + ["An Objektkanten ausrichten", "Aligner sur les bords des objets", "Alinear a los bordes del objeto", "オブジェクトの端に揃える"], + ), + ( + "Align to Object Centers", + ["An Objektmitten ausrichten", "Aligner sur les centres des objets", "Alinear a los centros del objeto", "オブジェクトの中心に揃える"], + ), + ("Smart Dimensions", ["Smarte Maße", "Mesures intelligentes", "Dimensiones inteligentes", "スマートサイズ"]), + ("Smart Spacing", ["Smarte Abstände", "Espacements intelligents", "Espaciado inteligente", "スマート間隔"]), + ("Snap to Zone", ["Fangzone", "Zone de magnétisme", "Zona de ajuste", "スナップゾーン"]), // Window and panels. ("Properties", ["Eigenschaften", "Propriétés", "Propiedades", "プロパティ"]), ("Layers", ["Ebenen", "Calques", "Capas", "レイヤー"]), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8712d42ce..004c38002 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,16 @@ pub struct SavedWorkspace { pub floating: Vec<(String, [f32; 2])>, } +/// A missing on-by-default bool stays on. `bool::default` is false. +fn default_true() -> bool { + true +} + +/// A missing snap zone stays at the factory width, in screen pixels. +fn default_zone() -> f64 { + 4.0 +} + /// Persisted UI state. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -162,6 +172,19 @@ pub struct UiState { pub text_style_tab: u8, pub guides_locked: bool, pub smart_guides: bool, + #[serde(default = "default_true")] + pub snap_to_guides: bool, + pub snap_to_document_grid: bool, + #[serde(default = "default_true")] + pub align_edges: bool, + #[serde(default = "default_true")] + pub align_centers: bool, + #[serde(default = "default_true")] + pub smart_dimensions: bool, + #[serde(default = "default_true")] + pub smart_spacing: bool, + #[serde(default = "default_zone")] + pub snap_zone: f64, /// Window > Contextual Task Bar. pub task_bar: bool, /// Help › About DesignCraft is open. @@ -228,6 +251,13 @@ impl Default for UiState { text_style_tab: 0, guides_locked: false, smart_guides: true, + snap_to_guides: true, + snap_to_document_grid: false, + align_edges: true, + align_centers: true, + smart_dimensions: true, + smart_spacing: true, + snap_zone: 4.0, task_bar: true, about: false, pending_urls: Vec::new(), @@ -239,6 +269,23 @@ impl Default for UiState { } } +impl UiState { + /// Copies the live view switches into the snap engine's view. + pub fn snap_view(&self) -> SnapView { + SnapView { + snap_to_guides: self.snap_to_guides, + snap_to_document_grid: self.snap_to_document_grid, + show_guides: self.guides, + smart_guides: self.smart_guides, + align_edges: self.align_edges, + align_centers: self.align_centers, + smart_dimensions: self.smart_dimensions, + smart_spacing: self.smart_spacing, + zone_px: self.snap_zone, + } + } +} + /// Per-document view: zoom (screen points per document point) and the canvas point at the /// top-left of the canvas area. #[derive(Clone, Copy, Debug, PartialEq, Serialize)] @@ -429,7 +476,7 @@ impl DesignApp { self.pane = pane; } pub fn view_info(&self) -> ViewInfo { - ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: SnapView::FACTORY, unit: self.ui.units } + ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), unit: self.ui.units } } pub fn status(&mut self, s: impl Into) { @@ -806,3 +853,20 @@ pub fn now_ms() -> f64 { 0.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snap_view_uses_the_saved_switches() { + let mut ui = UiState::default(); + assert!(ui.snap_view().snap_to_guides); + assert!(!ui.snap_view().snap_to_document_grid); + assert_eq!(ui.snap_view().zone_px, 4.0); + ui.snap_to_guides = false; + ui.snap_zone = 0.0; + assert!(!ui.snap_view().snap_to_guides); + assert_eq!(ui.snap_view().zone_px, 0.0); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 147964e29..52f55796f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), + ("view.snapToGuides", "Snap to Guides", Some("Cmd+Shift+;"), "{}"), + ("view.snapToDocumentGrid", "Snap to Document Grid", None, "{}"), + ("view.smartGuides", "Smart Guides", None, "{}"), ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"), ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"), @@ -584,6 +587,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", ">Grids & Guides", "ui:view.guides", + "ui:view.snapToGuides", + "ui:view.snapToDocumentGrid", + "ui:view.smartGuides", "ui:view.baselineGrid", "-", "ui:app.deleteAllGuides", @@ -959,6 +965,11 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option "fast", designcraft_render::DisplayQuality::Typical => "typical", @@ -1042,6 +1053,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.rulers), "view.guides" => flag(&mut app.ui.guides), + "view.snapToGuides" => flag(&mut app.ui.snap_to_guides), + "view.snapToDocumentGrid" => flag(&mut app.ui.snap_to_document_grid), + "view.smartGuides" => flag(&mut app.ui.smart_guides), "view.baselineGrid" => flag(&mut app.ui.baseline_grid), "view.textThreads" => flag(&mut app.ui.text_threads), "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters), @@ -1606,6 +1620,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.typicalDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Typical, "view.highQualityDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::High, "view.guides" => app.ui.guides, + "view.snapToGuides" => app.ui.snap_to_guides, + "view.snapToDocumentGrid" => app.ui.snap_to_document_grid, + "view.smartGuides" => app.ui.smart_guides, "view.baselineGrid" => app.ui.baseline_grid, "view.textThreads" => app.ui.text_threads, "view.hiddenCharacters" => app.ui.hidden_characters, diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 06deaf1d6..106f50717 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -358,8 +358,10 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if guides != app.ui.guides { let _ = app.run("view.guides", json!({})); } + if smart != app.ui.smart_guides { + let _ = app.run("view.smartGuides", json!({})); + } app.ui.guides_locked = locked; - app.ui.smart_guides = smart; rule(ui); section(ui, "Quick Actions", true); let (a, b) = button_pair(ui, "Import File", "Insert Page");