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