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

commit111bcabfe3c2c7658e75f1a219dcf170ff3b3455
parenta0ff69185b
authorLucas Galante <[email protected]>
date2026-09-23 10:23
feat: the palette heads with the open project's path

A row rather than a command (PATH_ROW_ID): the label is the path, the chord
column carries the file name — the palette's readout of what is being
edited, in the column a command's chord would use — and picking it copies
the path to the clipboard and closes, a copy being done the moment it
happens. It ranks against the path text like any other row.

The label truncates on the LEFT (`Row::truncate_head`, the paint's
`fit_head`): the tail of a path identifies it, and a row cut down to
`/home/me/pro...` would name every project in the directory equally badly.

There is no row when no project is loaded. The bundled
default_project.json leaves `loaded_project_path` None on purpose — the
window title and Set As Default take the same position — and a row offering
to copy a path into a versioned file in the source tree would be a trap.
`project_path_readout` reads the name with `file_name()`, the same call the
window title makes, so the two cannot disagree about what is open.

The clipboard write itself is cfg(not(test)): wl-copy has to outlive its
caller to serve the selection, and it inherits the test binary's captured
stdout, so a test that really copied left cargo waiting on a pipe held open
by a clipboard daemon — indistinguishable from a hung suite.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md     | 22 +++++++++++++
 src/dialog.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs   | 76 ++++++++++++++++++++++++++++++++++++++++-----
 3 files changed, 186 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index d7e2f52..4519a21 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1086,6 +1086,28 @@ viewer state. `dialog_toggle_rows_cover_every_toggle_command` fails when a
 `toggle_*` / `show_*_pane` command is added without an arm in the table,
 because the miss is silent — the row just ships plain.
 
+**The open project's PATH heads the Commands list**, as a row rather than a
+command (`PATH_ROW_ID`): the label is the path, the chord column carries the
+file name — the palette's readout of what is being edited, in the column a
+command's chord would use — and picking it copies the path to the clipboard
+and closes, a copy being done the moment it happens. It ranks against the
+path text like any other row, so a query finds or drops it.
+
+Two details. The label truncates on the LEFT (`Row::truncate_head`, the
+paint's `fit_head`), because the tail of a path is what identifies it and a
+row cut down to `/home/me/pro...` would name every project in the directory
+equally badly. And there is NO row when no project is loaded: the bundled
+`default_project.json` leaves `loaded_project_path` None on purpose (the
+window title and Set As Default take the same position), and a row offering
+to copy a path into a versioned file in the source tree would be a trap.
+`project_path_readout` reads the name with `file_name()`, the same call the
+window title makes, so the two cannot disagree about what is open.
+
+The actual clipboard write is `#[cfg(not(test))]`. `wl-copy` has to OUTLIVE
+its caller to serve the selection, and it inherits the test binary's captured
+stdout — so a test that really copied left cargo waiting on a pipe held open
+by a clipboard daemon, which looks exactly like a hung suite.
+
 **The zoom slider is a row of the Commands list, not a command.** While the
 network pane is focused — and only then, since zoom is that pane's — the
 list heads with a "Zoom" row (`ZOOM_ROW_ID`) carrying a `slider`: the
diff --git a/src/dialog.rs b/src/dialog.rs
index 6517ec5..9529bb1 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -93,6 +93,11 @@ pub struct Row {
     /// nudged by the arrow keys while selected; picking it runs nothing.
     /// `None` for every other row. There is at most one such row.
     pub slider: Option<f32>,
+    /// Truncate the label on the LEFT when it does not fit, rather than on
+    /// the right: the tail of a path is what identifies it, and a row that
+    /// cut `/home/me/projects/thing` down to `/home/me/pro...` would name
+    /// every project in the directory equally badly.
+    pub truncate_head: bool,
 }
 
 /// The dialog's outer size. Fixed rather than proportional: it is a focused
@@ -660,13 +665,22 @@ impl Paint for Dialog {
         // the occluder the dialog itself registers. The cost is that bounds
         // no longer trim an overlong label, so the rows truncate by hand.
         let own = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
+        let cols_for = |width: f32| -> usize {
+            (width / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize
+        };
         let fit = |text: &str, width: f32| -> String {
             if width <= 0.0 {
                 return String::new();
             }
-            let cols = (width / display::measure_text_width("M", &family, font_size).max(1.0))
-                .floor() as usize;
-            display::truncate_tail(text, cols)
+            display::truncate_tail(text, cols_for(width))
+        };
+        // The same budget, cut from the other end — a path's tail is what
+        // identifies it.
+        let fit_head = |text: &str, width: f32| -> String {
+            if width <= 0.0 {
+                return String::new();
+            }
+            display::truncate_head(text, cols_for(width))
         };
 
         // --- The header. In AddNode there are no halves to move between, so
@@ -817,7 +831,11 @@ impl Paint for Dialog {
                 label_x += side + 8.0;
             }
             ctx.text_with(
-                fit(&row.label, label_right - label_x),
+                if row.truncate_head {
+                    fit_head(&row.label, label_right - label_x)
+                } else {
+                    fit(&row.label, label_right - label_x)
+                },
                 label_x,
                 ty,
                 font_size,
@@ -1065,6 +1083,14 @@ use crate::slots::{DIALOG_IDX, DIALOG_PARAMS_IDX};
 /// the dialog up, the way the toggle rows stay up.
 pub const ZOOM_ROW_ID: &str = "zoom_level";
 
+/// The Commands list's other non-command row: the open project's PATH, with
+/// its file name in the chord column the way a command's chord sits there —
+/// the palette's readout of what is being edited. Picking it copies the path
+/// to the clipboard, which is the one thing anyone wants a path on screen
+/// for. It heads the list, where it reads as the document the rest of the
+/// commands act on.
+pub const PATH_ROW_ID: &str = "project_path";
+
 /// Where a Settings row's value actually lives.
 ///
 /// Not the live `State` fields, and not `DesignSettings`: both are DOWNSTREAM
@@ -1309,8 +1335,32 @@ impl State {
                     }),
                     toggle: self.command_toggle_state(c.id),
                     slider: None,
+                    truncate_head: false,
                 })
                 .collect();
+                // The open project's path heads the list — ranked against
+                // the path text, so typing any part of it (the project's
+                // name included, that being the tail) finds or drops the row
+                // like any other. Absent when no project is loaded: the
+                // bundled `default_project.json` leaves `loaded_project_path`
+                // None on purpose, and a row offering to copy a path to a
+                // versioned file in the source tree would be a trap.
+                if let Some((path, name)) = self.project_path_readout() {
+                    if !crate::command::fuzzy_rank(&query, &[path.as_str()]).is_empty() {
+                        rows.insert(
+                            0,
+                            Row {
+                                id: PATH_ROW_ID.to_string(),
+                                label: path,
+                                chord: name,
+                                swatch: None,
+                                toggle: None,
+                                slider: None,
+                                truncate_head: true,
+                            },
+                        );
+                    }
+                }
                 // The zoom slider heads the network pane's list, ranked like
                 // a row labelled "Zoom" so a query still finds (or drops) it.
                 if self.focused_context() == crate::command::Context::Network
@@ -1328,6 +1378,7 @@ impl State {
                             swatch: None,
                             toggle: None,
                             slider: Some(self.zoom_percent()),
+                            truncate_head: false,
                         },
                     );
                 }
@@ -1355,6 +1406,7 @@ impl State {
                         swatch: None,
                         toggle: None,
                         slider: None,
+                        truncate_head: false,
                     })
                     .collect()
             }
@@ -1362,6 +1414,37 @@ impl State {
         self.slots.dialog.set_rows(rows);
     }
 
+    /// The open project's path and its file name, for the palette's path row
+    /// — `None` when no project is loaded.
+    ///
+    /// The name is `file_name()`, which is the same thing the WINDOW TITLE
+    /// shows, so the palette and the title bar cannot disagree about what is
+    /// open. A project is a DIRECTORY holding `state.json`, so that name is
+    /// the directory's; the bundled `default_project.json` is the one single
+    /// file, and it never gets here because loading it leaves
+    /// `loaded_project_path` None — the app's own position is that nothing is
+    /// loaded, and Set As Default says the same.
+    pub fn project_path_readout(&self) -> Option<(String, String)> {
+        let path = self.loaded_project_path.as_ref()?;
+        let name = path.file_name()?.to_string_lossy().into_owned();
+        Some((path.to_string_lossy().into_owned(), name))
+    }
+
+    /// Put the open project's path on the clipboard, returning what was
+    /// copied — the palette's path row, and the only thing a path on screen
+    /// is ever wanted for. `None` when there is no project to name.
+    pub fn copy_project_path(&mut self) -> Option<String> {
+        let (path, _) = self.project_path_readout()?;
+        // Not under test. `wl-copy` has to OUTLIVE its caller to serve the
+        // selection, and it inherits the test binary's captured stdout — so a
+        // test that really copied left cargo waiting on a pipe held open by a
+        // clipboard daemon, which looks exactly like a hung test suite.
+        #[cfg(not(test))]
+        cce_ui::widget::clipboard::copy_to_clipboard(&path);
+        self.update_status_text(&format!("Copied {path}"));
+        Some(path)
+    }
+
     /// The network zoom as the slider row reads it: the current x pitch as a
     /// percentage of the configured one, so 100 is Reset Zoom.
     pub fn zoom_percent(&self) -> f32 {
@@ -1795,6 +1878,14 @@ impl State {
         if mode == Mode::Tabbed && id == ZOOM_ROW_ID {
             return;
         }
+        // The path row: copy, say so, and close. A copy is done the moment it
+        // happens — unlike a toggle, there is nothing to sit and adjust — so
+        // it leaves the way a command does.
+        if mode == Mode::Tabbed && id == PATH_ROW_ID {
+            self.close_dialog();
+            self.copy_project_path();
+            return;
+        }
         if mode == Mode::Tabbed && self.command_toggle_state(&id).is_some() {
             self.run_command(&id);
             self.refresh_dialog_toggles();
diff --git a/src/main.rs b/src/main.rs
index 41ba624..9d0ba6b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3475,7 +3475,7 @@ mod tests {
         WidgetHost::set_rect(&mut d, 0.0, 0.0, 520.0, 420.0);
         let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 520.0, height: 420.0 };
         let rows: Vec<Row> = (0..60)
-            .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None })
+            .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None, truncate_head: false })
             .collect();
         d.set_rows(rows);
         d.set_page(10);
@@ -3521,7 +3521,7 @@ mod tests {
         d.set_visible(true);
         WidgetHost::set_rect(&mut d, 0.0, 0.0, 520.0, 420.0);
         let rows: Vec<Row> = (0..60)
-            .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None })
+            .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None, truncate_head: false })
             .collect();
         d.set_rows(rows);
         d.set_page(10);
@@ -8422,9 +8422,9 @@ mod tests {
         WidgetHost::set_rect(&mut d, 0.0, 0.0, 520.0, 420.0);
         let (id, ptr) = (d.id(), d.as_ptr_mut());
         ctx.register_widget(id, ptr);
-        let plain = |i: usize| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None };
+        let plain = |i: usize| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None, slider: None, truncate_head: false };
         d.set_rows(vec![
-            Row { id: "zoom_level".into(), label: "Zoom".into(), chord: String::new(), swatch: None, toggle: None, slider: Some(100.0) },
+            Row { id: "zoom_level".into(), label: "Zoom".into(), chord: String::new(), swatch: None, toggle: None, slider: Some(100.0), truncate_head: false },
             plain(1),
             plain(2),
         ]);
@@ -8498,8 +8498,8 @@ mod tests {
         let (id, ptr) = (d.id(), d.as_ptr_mut());
         ctx.register_widget(id, ptr);
         d.set_rows(vec![
-            Row { id: "zoom_level".into(), label: "Zoom".into(), chord: String::new(), swatch: None, toggle: None, slider: Some(100.0) },
-            Row { id: "show_grid".into(), label: "Show Grid".into(), chord: "Ctrl+G".into(), swatch: None, toggle: Some(true), slider: None },
+            Row { id: "zoom_level".into(), label: "Zoom".into(), chord: String::new(), swatch: None, toggle: None, slider: Some(100.0), truncate_head: false },
+            Row { id: "show_grid".into(), label: "Show Grid".into(), chord: "Ctrl+G".into(), swatch: None, toggle: Some(true), slider: None, truncate_head: false },
         ]);
         d.set_slider_range(20.0, 320.0);
         d.set_page(10);
@@ -8686,7 +8686,6 @@ mod tests {
     /// the expanse is only read back while its anchor is the live cursor.
     #[test]
     fn dragging_the_network_grid_expands_the_cursor() {
-        use crate::slots::CONTENT_IDX;
         use crate::window::{LocalPosition, WindowEvent};
         use cce_ui::widget::{ElementState, MouseButton};
         let mut state = State::new(false);
@@ -9288,6 +9287,69 @@ mod tests {
         }
     }
 
+    /// The Commands half heads with the open project's PATH: the label is the
+    /// path (truncated on the left when it does not fit — the tail is what
+    /// identifies it), the chord column is the file name, and picking it
+    /// copies the path and closes. With no project loaded there is no row,
+    /// which is the same position `loaded_project_path` and the window title
+    /// take about the bundled default.
+    #[test]
+    fn the_palette_heads_with_the_open_projects_path() {
+        use crate::dialog::PATH_ROW_ID;
+        let dir = std::env::temp_dir()
+            .join(format!("cce-designer-path-row-{}", std::process::id()))
+            .join("my_project");
+        let _ = fs::remove_dir_all(&dir);
+
+        let mut state = State::new(false);
+        state.focused_pane = crate::slots::RIGHT_MENUBAR_IDX;
+
+        // Nothing loaded — the bundled default leaves no path behind, so the
+        // palette offers no row rather than one naming a versioned file.
+        assert!(state.project_path_readout().is_none());
+        state.run_command("command_palette");
+        assert!(
+            !state.slots.dialog.rows.iter().any(|r| r.id == PATH_ROW_ID),
+            "no project, no row"
+        );
+        state.close_dialog();
+
+        state.save_to_file(&dir).expect("save");
+        state.load_from_file(&dir).expect("load");
+        assert_eq!(state.loaded_project_path.as_deref(), Some(dir.as_path()));
+
+        state.run_command("command_palette");
+        let row = state.slots.dialog.rows.first().expect("rows").clone();
+        assert_eq!(row.id, PATH_ROW_ID, "it heads the list");
+        assert_eq!(row.label, dir.to_string_lossy(), "the label is the whole path");
+        assert_eq!(row.chord, "my_project", "the file name reads in the chord column");
+        assert!(row.truncate_head, "a path is cut from the left");
+
+        // It ranks like any other row: a query that matches the path keeps it,
+        // one that does not drops it.
+        for c in ["m", "y", "_", "p"] {
+            state.dialog_key_input(&typed(c));
+        }
+        assert!(state.slots.dialog.rows.iter().any(|r| r.id == PATH_ROW_ID));
+        for c in ["z", "z", "z"] {
+            state.dialog_key_input(&typed(c));
+        }
+        assert!(!state.slots.dialog.rows.iter().any(|r| r.id == PATH_ROW_ID));
+
+        // Picking it copies the path and leaves, the way a command does.
+        state.close_dialog();
+        state.run_command("command_palette");
+        state.take_dialog_pick(PATH_ROW_ID.to_string());
+        assert!(!state.dialog_visible(), "a copy is done the moment it happens");
+        assert!(
+            state.last_status_text.contains(&dir.to_string_lossy().to_string()),
+            "the status line says what was copied: {}",
+            state.last_status_text
+        );
+
+        let _ = fs::remove_dir_all(dir.parent().unwrap());
+    }
+
     /// Tab opens the same plate in its AddNode mode: one list of node
     /// templates, no tab strip, no chord column.
     #[test]