graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: dialog Commands rows draw switches for toggle commands, and flip in place
Toggle commands (Show Grid, Square Aspect, the pane toggles, ...) now carry
their live state on their palette row, painted as the toolkit's own Toggle in
a right-hand column reserved for every row so the chord column keeps a
straight edge. Enter or a click on such a row runs the command, re-reads the
switches in place (not a re-rank, which would throw the selection to the top)
and leaves the dialog open, so several viewport toggles can be set together
while looking at the scene. Plain commands close the dialog as before.
`State::command_toggle_state` is the table, reading the same field each
command flips so the switch agrees with the View menu's checkmarks; snapping
is a switch only inside a viewer state. The selection and hover highlights
stop short of the switch column: a switch is carved out of what it stands on,
and carved out of the tinted highlight it vanished on exactly the row Enter
was about to flip.
Tests: Enter on a toggle row flips it and keeps the dialog open (by click
too), a plain command still closes it, and every toggle_* / show_*_pane
command must have a table entry.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 14 +++++++
src/dialog.rs | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
src/main.rs | 79 +++++++++++++++++++++++++++++++++++---
3 files changed, 202 insertions(+), 11 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e14a073..5a64dca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -781,6 +781,20 @@ Both modes share the plate, the keys and `fuzzy_rank`, which is the whole
point — the app used to put two filterable lists in front of the user that
looked and behaved nothing alike.
+**Toggle commands are switches in the Commands list, and picking one does
+not close it.** `State::command_toggle_state(id)` is the table: it reads the
+same field each toggle command flips (the read the View menu's checkmarks
+are set from), and a row it answers carries a `toggle` that paints as the
+toolkit's own `Toggle` in a right-hand column reserved for every row, so the
+chord column keeps a straight edge. Enter or a click on such a row runs the
+command, re-reads the switches in place (`refresh_dialog_toggles` — not
+`refresh_dialog_rows`, which re-ranks and would throw the selection to the
+top) and leaves the dialog up: Show Grid, Show Cube and Square Aspect are set
+together while looking at the viewport. Snapping is a switch only inside a
+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.
+
**Alt+D, not Super+D.** Every Super chord is the compositor's before any client
sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
diff --git a/src/dialog.rs b/src/dialog.rs
index 7374ea2..b35d870 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -80,6 +80,13 @@ pub struct Row {
/// The Wireframe Color command carries the live wire colour here, so
/// the palette shows what the setting currently is before it is opened.
pub swatch: Option<[f32; 4]>,
+ /// The current state of a TOGGLE command — Show Grid, Square Aspect,
+ /// the pane toggles — drawn as a switch in a column of its own, so the
+ /// list shows what each toggle currently is the way the View menu's
+ /// checkmarks do. `None` for a command that runs and is done. A row
+ /// that carries one is picked in place: the command flips, the switch
+ /// moves, and the dialog stays up — see `State::take_dialog_pick`.
+ pub toggle: Option<bool>,
}
/// The dialog's outer size. Fixed rather than proportional: it is a focused
@@ -95,6 +102,11 @@ const QUERY_H: f32 = 30.0;
pub const ROW_H: f32 = 24.0;
/// Side of a row's colour swatch, logical px.
pub const SWATCH_SIDE: f32 = 14.0;
+/// A toggle row's switch: the toolkit's `Toggle`, at the row's height less a
+/// hair of air, and about twice as wide as tall — the proportion the params
+/// pane's toggles have.
+pub const TOGGLE_W: f32 = 36.0;
+const TOGGLE_H: f32 = ROW_H - 4.0;
/// Gap between the tab strip, the query line and the list.
const GAP: f32 = 8.0;
@@ -196,10 +208,20 @@ pub struct Dialog {
/// the dialog, because the one claim serves two mechanisms that want
/// opposite answers.
occluding: bool,
+ /// The switch a toggle row draws, off and on — the toolkit's own
+ /// `Toggle`, painted by hand into the row, so a switch in the dialog IS
+ /// the switch in the params pane. Two stamps rather than one set per row
+ /// because `paint` takes `&self`, and building a widget per row per
+ /// frame would be silly.
+ toggle_stamps: [Adapted<Toggle>; 2],
}
impl Dialog {
pub fn new() -> Adapted<Dialog> {
+ let mut off = Toggle::new();
+ off.set_toggled(false);
+ let mut on = Toggle::new();
+ on.set_toggled(true);
let mut d = Adapted::new(Dialog {
mode: Mode::Tabbed,
tab: Tab::Commands,
@@ -217,6 +239,7 @@ impl Dialog {
activated: None,
tab_click: None,
occluding: true,
+ toggle_stamps: [off, on],
});
d.set_visible(false);
d
@@ -590,7 +613,14 @@ impl Paint for Dialog {
// --- The rows. The chord column is right-aligned against the list's
// right edge rather than padded out to a fixed width: the label is
// what gets read, so it is the label that keeps the stable left edge.
+ //
+ // The switches get a column of their own at the far right, reserved
+ // for EVERY row as soon as any row has one, so the chord column keeps
+ // a straight edge whether or not the row beside it toggles. Without
+ // the reservation the chords step left on toggle rows and the column
+ // reads as ragged, which is worse than the strip of air it costs.
let list = list_rect(rect);
+ let toggle_col = if self.rows.iter().any(|r| r.toggle.is_some()) { TOGGLE_W + 12.0 } else { 0.0 };
if self.rows.is_empty() {
let ty = cce_ui::layout::align_text_y(list.y, ROW_H, font_size, 0.0);
let empty = match self.mode {
@@ -604,11 +634,18 @@ impl Paint for Dialog {
for i in self.first_row()..self.rows.len() {
let Some(r) = self.row_rect(rect, i) else { break };
let row = &self.rows[i];
+ // The highlight stops short of the switch column. A switch has
+ // no face of its own — it is carved out of whatever it stands
+ // on, the DE's convention — and carved out of the selection's
+ // tinted bevel it vanished outright: the selected row, the one
+ // row whose state Enter is about to flip, was the one row whose
+ // state could not be read. On the plate it reads like the rest.
+ let hl = Rect { width: (r.width - toggle_col).max(0.0), ..r };
if i == self.selected {
- ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
- ctx.bevel_tinted(r, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
+ ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
+ ctx.bevel_tinted(hl, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
} else if self.hover_row == Some(i) {
- ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+ ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
}
let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
let chord_w = if row.chord.is_empty() {
@@ -618,7 +655,8 @@ impl Paint for Dialog {
};
// The label's clip stops short of the chord column so a long
// label is cut by it rather than running under it.
- let label_right = r.x + r.width - 8.0 - if chord_w > 0.0 { chord_w + 12.0 } else { 0.0 };
+ let chord_right = r.x + r.width - 8.0 - toggle_col;
+ let label_right = chord_right - if chord_w > 0.0 { chord_w + 12.0 } else { 0.0 };
let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
// The swatch: a small rounded tile ahead of the label, ringed
// faintly so a colour near the plate's own does not vanish into
@@ -644,7 +682,7 @@ impl Paint for Dialog {
if chord_w > 0.0 {
ctx.text_with(
row.chord.clone(),
- r.x + r.width - 8.0 - chord_w,
+ chord_right - chord_w,
ty,
font_size,
[0x85, 0x85, 0x92],
@@ -652,6 +690,15 @@ impl Paint for Dialog {
own,
);
}
+ if let Some(on) = row.toggle {
+ let tr = Rect {
+ x: r.x + r.width - 8.0 - TOGGLE_W,
+ y: r.y + (r.height - TOGGLE_H) * 0.5,
+ width: TOGGLE_W,
+ height: TOGGLE_H,
+ };
+ Paint::paint(&*self.toggle_stamps[on as usize], tr, ctx);
+ }
}
});
@@ -1033,6 +1080,7 @@ impl State {
swatch: (c.id == "wireframe_color").then(|| {
cce_ui::color::to_linear([self.wire_color[0], self.wire_color[1], self.wire_color[2], 1.0])
}),
+ toggle: self.command_toggle_state(c.id),
})
.collect(),
Mode::AddNode => {
@@ -1055,6 +1103,7 @@ impl State {
label: offered[i].to_string(),
chord: String::new(),
swatch: None,
+ toggle: None,
})
.collect()
}
@@ -1062,6 +1111,54 @@ impl State {
self.slots.dialog.set_rows(rows);
}
+ /// What a toggle command's switch currently shows, or `None` for a
+ /// command that is not a toggle.
+ ///
+ /// Read off the very field each command flips in `execute_action` /
+ /// `execute_menu_action` — the same read the View menu's checkmarks are
+ /// set from — so the switch cannot disagree with the menu. Snapping is
+ /// a toggle only INSIDE a viewer state; outside one the command does
+ /// nothing but say so, and a switch on a row that cannot flip would be a
+ /// lie, so the row is plain until a state is entered.
+ /// `dialog_toggle_rows_cover_every_toggle_command` keeps this list and
+ /// the registry's `toggle_*` / `show_*_pane` rows in step.
+ pub fn command_toggle_state(&self, id: &str) -> Option<bool> {
+ Some(match id {
+ "toggle_grid" => self.viewport().show_grid,
+ "toggle_cube" => self.viewport().show_cube,
+ "toggle_origin" => self.viewport().show_origin,
+ "toggle_camera_pivot" => self.viewport().show_camera_pivot,
+ "toggle_wireframe" => self.wireframe,
+ "toggle_square_viewport" => self.square_viewport,
+ "toggle_network_plate" => self.network_plate,
+ "toggle_circular_pane" => self.circular_network_pane,
+ "detach_circular_window" => self.detached_circular_network,
+ "toggle_spreadsheet" => self.show_spreadsheet,
+ "show_network_pane" => self.show_network,
+ "show_viewport_pane" => self.show_viewport,
+ "show_parameters_pane" => self.show_parameters,
+ "show_playbar_pane" => self.show_playbar,
+ "toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
+ _ => return None,
+ })
+ }
+
+ /// Re-read every row's switch from the live state, touching nothing
+ /// else — not the ranking, not the selection, not the scroll. This is
+ /// what a toggle pick runs instead of `refresh_dialog_rows`: the rows are
+ /// the same rows, only a switch has moved, and re-ranking would throw the
+ /// selection back to the top of a list the user is still working down.
+ fn refresh_dialog_toggles(&mut self) {
+ if self.slots.dialog.mode != Mode::Tabbed {
+ return;
+ }
+ let states: Vec<Option<bool>> =
+ self.slots.dialog.rows.iter().map(|r| self.command_toggle_state(&r.id)).collect();
+ for (row, state) in self.slots.dialog.rows.iter_mut().zip(states) {
+ row.toggle = state;
+ }
+ }
+
/// The Settings half's rows, each read from whatever owns its value.
///
/// Returns `ParamDef`s rather than display triples so the encoding
@@ -1391,8 +1488,21 @@ impl State {
/// `cce-cloud` palette, a file chooser) does not come up behind the
/// dialog. The dialog's own row is the exception: toggling it here would
/// reopen what was just closed.
+ ///
+ /// A TOGGLE row does not close at all. It is a switch, and a switch you
+ /// can only flip once before the panel it is on vanishes is a button
+ /// with extra steps: Show Grid, Show Cube and Square Aspect are the
+ /// kind of thing you set together, looking at the viewport, and the
+ /// dialog staying up is what lets you. The command runs, the switches
+ /// re-read, and the selection stays where it was — by Enter or by a
+ /// click, since both arrive here.
pub(crate) fn take_dialog_pick(&mut self, id: String) {
let mode = self.slots.dialog.mode;
+ if mode == Mode::Tabbed && self.command_toggle_state(&id).is_some() {
+ self.run_command(&id);
+ self.refresh_dialog_toggles();
+ return;
+ }
let (gx, gy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
self.close_dialog();
match mode {
diff --git a/src/main.rs b/src/main.rs
index 77a2e82..9f8b24f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3279,7 +3279,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 })
+ .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None })
.collect();
d.set_rows(rows);
d.set_page(10);
@@ -3325,7 +3325,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 })
+ .map(|i| Row { id: format!("c{i}"), label: format!("Command {i}"), chord: String::new(), swatch: None, toggle: None })
.collect();
d.set_rows(rows);
d.set_page(10);
@@ -7533,21 +7533,88 @@ mod tests {
let mut state = State::new(false);
state.run_command("toggle_dialog");
- for c in ["s", "q", "u", "a"] {
+ for c in ["d", "e", "s", "e", "l"] {
state.dialog_key_input(&typed(c));
}
- assert_eq!(state.slots.dialog.query, "squa");
+ assert_eq!(state.slots.dialog.query, "desel");
assert_eq!(
state.slots.dialog.selected_id(),
- Some("toggle_square_viewport"),
+ Some("deselect"),
"rows: {:?}",
state.slots.dialog.rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>()
);
+ let row = &state.slots.dialog.rows[state.slots.dialog.selected];
+ assert_eq!(row.toggle, None, "Deselect runs and is done; it draws no switch");
+
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(!state.dialog_visible(), "a plain command closes the dialog behind it");
+ }
+
+ /// A toggle row is a switch: Enter flips it, the switch on the row moves,
+ /// and the dialog stays up with the selection where it was — so Show
+ /// Grid, Show Cube and Square Aspect can be set together, looking at the
+ /// viewport, instead of reopening the dialog for each.
+ #[test]
+ fn dialog_enter_on_a_toggle_row_flips_it_and_keeps_the_dialog_open() {
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ for c in ["s", "q", "u", "a"] {
+ state.dialog_key_input(&typed(c));
+ }
+ assert_eq!(state.slots.dialog.selected_id(), Some("toggle_square_viewport"));
let before = state.square_viewport;
+ let row = state.slots.dialog.rows[state.slots.dialog.selected].clone();
+ assert_eq!(row.toggle, Some(before), "the switch shows the live value");
+
state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
assert_eq!(state.square_viewport, !before, "Enter ran the command");
- assert!(!state.dialog_visible(), "and closed behind it");
+ assert!(state.dialog_visible(), "and the dialog stayed up");
+ assert_eq!(state.slots.dialog.query, "squa", "with its query intact");
+ assert_eq!(state.slots.dialog.selected_id(), Some("toggle_square_viewport"), "and its selection");
+ let row = &state.slots.dialog.rows[state.slots.dialog.selected];
+ assert_eq!(row.toggle, Some(!before), "the switch moved with the value");
+
+ // And back again, without leaving.
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert_eq!(state.square_viewport, before);
+ assert!(state.dialog_visible());
+ assert_eq!(state.slots.dialog.rows[state.slots.dialog.selected].toggle, Some(before));
+
+ // A click on the row is the same pick as Enter.
+ state.take_dialog_pick("toggle_square_viewport".to_string());
+ assert_eq!(state.square_viewport, !before);
+ assert!(state.dialog_visible(), "a clicked switch keeps the dialog up too");
+ }
+
+ /// Every toggle command in the registry draws a switch, and every switch
+ /// names a command the registry has. `command_toggle_state` is a match on
+ /// id strings, so a `toggle_*` row added to the registry without an arm
+ /// there would silently ship as a plain row — this is what says so.
+ #[test]
+ fn dialog_toggle_rows_cover_every_toggle_command() {
+ let state = State::new(false);
+ // Named like toggles, but not switches: Dialog toggles the dialog
+ // itself (picking it is a no-op), Configure focuses a pane, and
+ // Snapping is a switch only inside a viewer state — asserted below.
+ let not_switches = ["toggle_dialog", "toggle_configure", "toggle_snap"];
+ for c in crate::command::COMMANDS {
+ let looks_like_toggle = c.id.starts_with("toggle_")
+ || (c.id.starts_with("show_") && c.id.ends_with("_pane"));
+ let is_switch = state.command_toggle_state(c.id).is_some();
+ if looks_like_toggle && !not_switches.contains(&c.id) {
+ assert!(is_switch, "{} is a toggle command with no switch", c.id);
+ } else if !looks_like_toggle && c.id != "detach_circular_window" {
+ assert!(!is_switch, "{} draws a switch but is not a toggle", c.id);
+ }
+ }
+ assert!(state.command_toggle_state("detach_circular_window").is_some());
+ assert_eq!(state.command_toggle_state("toggle_snap"), None, "no viewer state, no switch");
+ assert_eq!(state.command_toggle_state("no_such_command"), None);
+
+ // The switches agree with the fields the commands flip.
+ assert_eq!(state.command_toggle_state("toggle_grid"), Some(state.viewport().show_grid));
+ assert_eq!(state.command_toggle_state("show_network_pane"), Some(state.show_network));
}
/// Backspace walks the query back, and the ranking follows it.