git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitf16bc6e322ea9e10bb59198ca4c6f79a5cd043fc
parente3d6d47f2c
authorLucas Galante <[email protected]>
date2026-08-23 13:51
feat: Main's "Set As Default" — open the current project at startup

A new File-section button on the Main node stores the currently-loaded
project's path as `default_project` in state.kdl; the main window loads it at
startup in place of the bundled default_project.json. A pointer, deliberately
NOT a rewrite of default_project.json — that file is versioned and doubles as
the detached-window sync channel, so "make this the default" must not touch
it (and the detached windows keep seeding from it, ignoring the setting).

A scratch project has no path, so the click is a no-op with a stderr note. A
default whose path has since vanished is dropped from the settings at launch
— falling back to the bundled project once, not failing on every start.

DesignSettings gains the field; its KDL codec is split into to_kdl_str /
from_kdl_str so the round trip is testable without touching the real
~/.config (serde alone passing proves nothing about what
json_to_kdl_string / parse_kdl_to_json preserve).

Live-verified in a shadow session (own $HOME): save under a fresh path, click
Set As Default via menu_action, restart -> the marker node is back; point the
setting at a deleted path, restart -> bundled default, setting self-cleared.

 CLAUDE.md          |  8 ++++++++
 src/app.rs         | 36 ++++++++++++++++++++++++++++++++----
 src/application.rs |  4 ++++
 src/main.rs        | 44 ++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs     | 42 ++++++++++++++++++++++++++++++++++++++++--
 5 files changed, 128 insertions(+), 6 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4eaa527..a50c534 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -149,6 +149,14 @@ is the introspection surface.
 
 ### App-written settings: `~/.config/cce/cce-designer/state.kdl`
 
+`default_project` in state.kdl points at the project the main window opens on
+startup (the Main node's File > "Set As Default" button; absent = the bundled
+`default_project.json`). It is a POINTER, never a rewrite of
+default_project.json — that file is versioned and is the detached-window sync
+channel. A default whose path no longer exists is dropped from the settings on
+launch. Detached windows ignore it: they must keep seeding from the sync
+channel.
+
 `DesignSettings` (viewport/graph display state the app rewrites itself:
 colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
 `config.kdl`, which is the user-authored toolkit-config override slot that
diff --git a/src/app.rs b/src/app.rs
index a90d79a..f2b0436 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -418,6 +418,14 @@ pub fn configured_grid_geometry() -> (f32, f32, f32, f32) {
 pub struct DesignSettings {
     #[serde(default)]
     pub viewport: ViewportSettings,
+    /// Project to open at startup instead of the bundled default — the Main
+    /// node's "Set As Default" button. A path string (what
+    /// `loaded_project_path` held when it was set); absent = the bundled
+    /// `default_project.json`. Deliberately NOT the project file itself:
+    /// that file is versioned AND is the detached-window sync channel, so
+    /// "make this the default" must not rewrite it.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub default_project: Option<String>,
 }
 
 fn float_array_to_hex(rgb: &[f32; 3]) -> String {
@@ -444,7 +452,11 @@ impl DesignSettings {
 
     fn load_kdl(path: &std::path::Path) -> Option<Self> {
         let content = fs::read_to_string(path).ok()?;
-        let mut json_val = cce_ui::config::parse_kdl_to_json(&content);
+        Some(Self::from_kdl_str(&content))
+    }
+
+    pub(crate) fn from_kdl_str(content: &str) -> Self {
+        let mut json_val = cce_ui::config::parse_kdl_to_json(content);
         // Convert hex strings back to color arrays
         if let Some(obj) = json_val.as_object_mut() {
             if let Some(viewport) = obj.get_mut("viewport").and_then(|v| v.as_object_mut()) {
@@ -464,7 +476,7 @@ impl DesignSettings {
                 }
             }
         }
-        Some(serde_json::from_value::<Self>(json_val).unwrap_or_else(|_| Self::default()))
+        serde_json::from_value::<Self>(json_val).unwrap_or_else(|_| Self::default())
     }
 
     fn load() -> Self {
@@ -497,6 +509,12 @@ impl DesignSettings {
         if let Some(parent) = path.parent() {
             let _ = fs::create_dir_all(parent);
         }
+        if let Some(kdl_str) = self.to_kdl_str() {
+            let _ = fs::write(path, kdl_str);
+        }
+    }
+
+    pub(crate) fn to_kdl_str(&self) -> Option<String> {
         if let Ok(mut json_val) = serde_json::to_value(self) {
             // Convert color arrays to hex strings
             if let Some(obj) = json_val.as_object_mut() {
@@ -515,9 +533,9 @@ impl DesignSettings {
                     }
                 }
             }
-            let kdl_str = cce_ui::config::json_to_kdl_string(&json_val);
-            let _ = fs::write(path, kdl_str);
+            return Some(cce_ui::config::json_to_kdl_string(&json_val));
         }
+        None
     }
 }
 
@@ -796,6 +814,10 @@ pub struct State {
     pub floating_spreadsheet_inset_left: f32,
     pub floating_spreadsheet_inset_right: f32,
     pub loaded_project_path: Option<std::path::PathBuf>,
+    /// The configured startup project (`DesignSettings::default_project`),
+    /// mirrored live so "Set As Default" can rewrite it and `save_settings` —
+    /// which reconstructs DesignSettings from live state — can carry it.
+    pub default_project_setting: Option<String>,
     pub last_saved_root_json: String,
     pub recent_files: Vec<std::path::PathBuf>,
     pub viewport_dirty: bool,
@@ -967,6 +989,7 @@ impl State {
                 grid_thickness: self.grid_thickness,
                 grid_color: self.viewport().grid_color,
             },
+            default_project: self.default_project_setting.clone(),
         };
         settings.save();
         self.last_design_mod_time = {
@@ -1404,6 +1427,9 @@ impl State {
             "New Project" | "New" => {
                 self.new_project();
             }
+            "Set As Default" => {
+                self.set_current_as_default();
+            }
             "Open" => {
                 self.open_file_chooser();
             }
@@ -2766,6 +2792,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             floating_spreadsheet_inset_left: 0.0,
             floating_spreadsheet_inset_right: 0.0,
             loaded_project_path: None,
+            default_project_setting: settings.default_project.clone(),
             last_saved_root_json: serde_json::to_string(&fs_root).unwrap_or_default(),
             recent_files,
             viewport_dirty: true,
@@ -5038,6 +5065,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     if Some(mod_time) != self.last_design_mod_time {
                         self.last_design_mod_time = Some(mod_time);
                          let settings = DesignSettings::load();
+                         self.default_project_setting = settings.default_project.clone();
                          self.square_viewport = settings.viewport.square;
                          self.grid_thickness = settings.viewport.grid_thickness;
                          self.viewport_mut().show_grid = settings.viewport.show_grid_enabled;
diff --git a/src/application.rs b/src/application.rs
index 1bfff82..128aa02 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -169,6 +169,10 @@ impl Application for State {
         // One MCP server per project: the detached windows are satellites of the
         // main one and would only collide on the port.
         if !is_detached_network && detached_pane.is_none() {
+            // The user's configured startup project, over the bundled default
+            // State::new seeded. Main window only: the detached windows read
+            // default_project.json as their sync channel and must keep it.
+            state.load_default_project_setting();
             start_mcp_server(sender);
         }
         state
diff --git a/src/main.rs b/src/main.rs
index 54a1406..dde00da 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -69,6 +69,50 @@ mod tests {
     use crate::shortcut::{Shortcut, ShortcutManager, Action};
     use crate::geometry::{GAttribute, GVertex, Geometry, line_vertices};
 
+    /// The default-project pointer must survive the KDL round trip state.kdl
+    /// actually goes through — serde alone passing means nothing if
+    /// json_to_kdl_string / parse_kdl_to_json drop or retype the field.
+    #[test]
+    fn test_default_project_setting_survives_the_kdl_round_trip() {
+        let mut settings = DesignSettings::default();
+        settings.default_project = Some("/home/user/projects/gears".to_string());
+        let kdl = settings.to_kdl_str().expect("settings serialize");
+        let back = DesignSettings::from_kdl_str(&kdl);
+        assert_eq!(back.default_project.as_deref(), Some("/home/user/projects/gears"));
+
+        // And absence stays absence — an unset default must not come back as
+        // Some("") and shadow the bundled project.
+        let none_kdl = DesignSettings::default().to_kdl_str().expect("serialize");
+        assert_eq!(DesignSettings::from_kdl_str(&none_kdl).default_project, None);
+    }
+
+    /// The button must exist on Main, inside the File section, before Exit.
+    #[test]
+    fn test_main_node_offers_set_as_default() {
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+        let main = state.fs_root.children.iter().find(|c| c.name == "Main").expect("Main node");
+        let names: Vec<&str> = main.params.iter().map(|p| p.name.as_str()).collect();
+        let idx = names.iter().position(|n| *n == "Set As Default").expect("Set As Default param");
+        let save_as = names.iter().position(|n| *n == "Save As").unwrap();
+        let exit = names.iter().position(|n| *n == "Exit").unwrap();
+        assert!(save_as < idx && idx < exit, "Set As Default out of place: {names:?}");
+        assert_eq!(main.params[idx].param_type, "button");
+    }
+
+    /// A scratch project has no path — the click must not invent a default.
+    #[test]
+    fn test_set_as_default_without_a_loaded_file_is_a_noop() {
+        let mut state = State::new(false);
+        assert_eq!(state.loaded_project_path, None);
+        let before = state.default_project_setting.clone();
+        // Deliberately NOT via set_current_as_default's saving path: with a
+        // loaded path it would write the real ~/.config state.kdl. The no-path
+        // arm does not save, so it is safe to exercise directly.
+        state.set_current_as_default();
+        assert_eq!(state.default_project_setting, before);
+    }
+
     /// The corner control has to land ON its plate: derived from the slot's live
     /// rect, an off-by-one in the inset would put the trigger outside the pane
     /// (unclickable, and painted over the neighbour) with nothing to catch it —
diff --git a/src/project.rs b/src/project.rs
index 32593fb..65e0f92 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -225,6 +225,43 @@ impl State {
         Ok(())
     }
 
+    /// The Main node's "Set As Default": remember the currently-loaded project
+    /// as what the app opens at startup. A pointer in state.kdl — NOT a rewrite
+    /// of the bundled default_project.json, which is versioned and doubles as
+    /// the detached-window sync channel. A scratch (never-saved) project has no
+    /// path to point at, so the click is a no-op with a note.
+    pub(crate) fn set_current_as_default(&mut self) {
+        match &self.loaded_project_path {
+            Some(path) => {
+                self.default_project_setting = Some(path.to_string_lossy().into_owned());
+                self.save_settings();
+            }
+            None => {
+                eprintln!("Set As Default: no project file is loaded — save the project first");
+            }
+        }
+    }
+
+    /// Open the configured startup project, if any. Main window only — the
+    /// detached windows must keep seeding from default_project.json, which is
+    /// their sync channel with the parent. A missing or unloadable default
+    /// falls back to what State::new already loaded, and a default that no
+    /// longer exists is dropped from the settings so it does not fail on every
+    /// launch from now on.
+    pub(crate) fn load_default_project_setting(&mut self) {
+        let Some(configured) = self.default_project_setting.clone() else { return };
+        let path = std::path::PathBuf::from(&configured);
+        if !path.exists() {
+            eprintln!("Default project is gone, clearing the setting: {configured}");
+            self.default_project_setting = None;
+            self.save_settings();
+            return;
+        }
+        if let Err(e) = self.load_from_file(&path) {
+            eprintln!("Failed to load default project {configured}: {e:?}");
+        }
+    }
+
     pub(crate) fn new_project(&mut self) {
         self.fs_root = FsNode {
             id: "root".to_string(),
@@ -378,6 +415,7 @@ impl State {
 
         ensure_param(main_node, "Save", "button", "", &[], None, None, None);
         ensure_param(main_node, "Save As", "button", "", &[], None, None, None);
+        ensure_param(main_node, "Set As Default", "button", "", &[], None, None, None);
         ensure_param(main_node, "Exit", "button", "", &[], None, None, None);
 
         ensure_param(main_node, "Edit", "section", "", &[], None, None, None);
@@ -507,8 +545,8 @@ impl State {
             }
         }
 
-        const MAIN_PARAM_ORDER: [&str; 19] = [
-            "File", "New Project", "Open", "Save", "Save As", "Exit",
+        const MAIN_PARAM_ORDER: [&str; 20] = [
+            "File", "New Project", "Open", "Save", "Save As", "Set As Default", "Exit",
             "Edit", "Undo", "Redo",
             "Network", "Zoom In", "Zoom Out",
             "Reset Zoom", "Detach Circular Window", "Circular Pane",