graphic design tool
git clone https://git.lucas.co/cce-designer.git
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",