graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a control in the palette lifts under the pointer
The dialog keeps the row whose switch, slider or colour well the pointer
is over, tells the toggle and slider stamps as each row is painted, and
tells a colour row's selector as the pointer crosses its band.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 13 +++++++++++
src/dialog.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++----
src/main.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 138 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8402d87..2301793 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3065,6 +3065,19 @@ dispatch into the dialog and puts it back, invalidating the coverage memo on bot
edges (the engine queries it on every left press, so lowering the flag alone
leaves a stale cached answer).
+**A control in the dialog lifts under the pointer** (`Dialog::hover_ctl`,
+since 2026-09-29): the row whose switch, slider (readout lane included)
+or colour well the pointer is over, found by `control_rect` on every
+move. The toggle and slider stamps serve every row, so each is told the
+hover as its row is painted (`Toggle::set_hovered`, `Slider::set_hovered`
+— the two setters cce-ui grew for it, since a stamp is never routed a
+`MouseEnter`); a colour row's selector is a widget of its own and is
+told by `MouseEnter` / `MouseLeave` through `color_event` as the pointer
+crosses its band. The hover clears with the row's when the pointer
+leaves the plate, since `broadcast_pointer` hands the dialog an
+off-screen position then. `a_palette_control_lifts_under_the_pointer`
+is the test.
+
**A captured pointer hovers no pane.** `State::broadcast_pointer` hands
every slot the pointer's position, or an off-screen one while
`pointer_captured` says a gesture or the dialog owns it — a widget or app
diff --git a/src/dialog.rs b/src/dialog.rs
index 664a5bc..56b2892 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -258,6 +258,13 @@ pub struct Dialog {
/// app's key handling both need it and neither has the rect to hand.
page: usize,
hover_row: Option<usize>,
+ /// The row whose CONTROL the pointer is over — its switch, its slider
+ /// (readout lane included), its colour well — so the control lifts
+ /// under the pointer as the same control does in the params pane. The
+ /// toggle and slider stamps are set from it as each row is painted; a
+ /// colour row's selector is a widget of its own and is told by
+ /// `MouseEnter` / `MouseLeave` as the pointer crosses its band.
+ hover_ctl: Option<usize>,
/// A row the pointer activated, drained by the app.
activated: Option<String>,
/// Whether the dialog is currently claiming its rect as an occluder — see
@@ -270,7 +277,7 @@ pub struct Dialog {
/// 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],
+ toggle_stamps: RefCell<[Adapted<Toggle>; 2]>,
/// The slider every slider row draws — the toolkit's own `Slider`, so a
/// slider in the dialog IS the slider in the params pane. One stamp for
/// all of them, set to each row's range and value as it is painted;
@@ -315,9 +322,10 @@ impl Dialog {
sb_drag_offset: 0.0,
page: 1,
hover_row: None,
+ hover_ctl: None,
activated: None,
occluding: true,
- toggle_stamps: [off, on],
+ toggle_stamps: RefCell::new([off, on]),
slider_stamp: RefCell::new(slider_stamp),
slider_drag: None,
slider_track: (0.0, 1.0),
@@ -603,6 +611,28 @@ impl Dialog {
Rect { x, y: r.y + 2.0, width: (right - x).max(10.0), height: ROW_H - 4.0 }
}
+ /// Where a row's control is, for the hover: the switch, the whole
+ /// slider (readout lane included), the colour band. None for a row
+ /// with no control the pointer can lift.
+ fn control_rect(&self, r: Rect, row: &Row) -> Option<Rect> {
+ match &row.control {
+ Some(Control::Toggle(_)) => Some(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,
+ }),
+ Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
+ Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
+ Some(Control::Choice { .. }) | None => None,
+ }
+ }
+
+ /// The row whose control is under the pointer.
+ pub fn hovered_control(&self) -> Option<usize> {
+ self.hover_ctl
+ }
+
/// The whole slider control: the band plus the readout lane ahead of it.
/// This is what the pointer tests against, so the wheel turns the slider
/// over the readout too.
@@ -915,7 +945,9 @@ impl Paint for Dialog {
width: TOGGLE_W,
height: TOGGLE_H,
};
- Paint::paint(&*self.toggle_stamps[*on as usize], tr, ctx);
+ let mut stamps = self.toggle_stamps.borrow_mut();
+ stamps[*on as usize].set_hovered(self.hover_ctl == Some(i));
+ Paint::paint(&*stamps[*on as usize], tr, ctx);
}
Some(Control::Slider { value, min, max, dec, suffix, .. }) => {
let band = self.slider_band_rect(r);
@@ -923,6 +955,7 @@ impl Paint for Dialog {
let mut stamp = self.slider_stamp.borrow_mut();
stamp.set_range(*min, *max);
stamp.set_scaled_value(*value);
+ stamp.set_hovered(self.hover_ctl == Some(i));
Paint::paint(&**stamp, band, ctx);
}
// The readout, right-aligned in its lane ahead of the
@@ -1098,8 +1131,27 @@ impl Input for Dialog {
}
self.sb_activity.set_hover(self.over_scrollbar(rect, *x, *y));
let row = self.row_at(rect, *x, *y);
- let changed = row != self.hover_row;
+ let ctl = row.filter(|&i| {
+ self.row_rect(rect, i)
+ .and_then(|r| self.control_rect(r, &self.rows[i]))
+ .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height)
+ });
+ let changed = row != self.hover_row || ctl != self.hover_ctl;
self.hover_row = row;
+ if ctl != self.hover_ctl {
+ // A colour well is a widget of its own: told the way
+ // the runner tells any widget.
+ for (i, entering) in [(self.hover_ctl, false), (ctl, true)] {
+ let Some(i) = i else { continue };
+ if self.rows.get(i).is_some_and(Row::is_color) {
+ if let Some(band) = self.row_rect(rect, i).map(|r| self.slider_band_rect(r)) {
+ let ev = if entering { Event::MouseEnter } else { Event::MouseLeave };
+ self.color_event(i, band, &ev, ectx);
+ }
+ }
+ }
+ self.hover_ctl = ctl;
+ }
changed
}
Event::MouseWheel { delta, x, y, .. } => {
diff --git a/src/main.rs b/src/main.rs
index dd6205b..2d458a8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13569,6 +13569,75 @@ mod tests {
/// ranked with the commands, so a query finds a colour the way it finds
/// a command. There is no second half: Tab in this mode does nothing,
/// and nothing draws a strip.
+ /// A control in the palette lifts under the pointer: the row whose
+ /// switch, slider or colour well the pointer is over is the hovered
+ /// control, and the label beside it is not.
+ #[test]
+ fn a_palette_control_lifts_under_the_pointer() {
+ use crate::dialog::{setting_row_id, Control, TOGGLE_W};
+ use crate::slots::DIALOG_IDX;
+ use crate::window::{LocalPosition, WindowEvent};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ state.run_command("command_palette");
+ // The Show Grid switch is a toggle row, Geometry Opacity a slider.
+ for c in ["s", "h", "o", "w", "g", "r", "i"] {
+ state.dialog_key_input(&typed(c));
+ }
+ let rows = &state.slots.dialog.rows;
+ let toggle = rows.iter().position(|r| r.id == "toggle_grid").expect("a Show Grid row");
+ assert!(matches!(rows[toggle].control, Some(Control::Toggle(_))));
+ let (dx, dy, dw, dh) = state.positions[DIALOG_IDX];
+ assert!(dw > 0.0 && dh > 0.0);
+ let at = |state: &mut State, x: f32, y: f32| {
+ state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+ state.slots.dialog.hovered_control()
+ };
+ // Walk down the switch column until the pointer is over the row's
+ // switch.
+ let switch_x = dx + dw - 8.0 - TOGGLE_W * 0.5;
+ let mut found = None;
+ let mut y = dy;
+ while y < dy + dh {
+ if at(&mut state, switch_x, y) == Some(toggle) {
+ found = Some(y);
+ break;
+ }
+ y += 4.0;
+ }
+ let y = found.expect("the switch is under the pointer somewhere down its column");
+ // The label beside it is not the control.
+ assert_eq!(at(&mut state, dx + 24.0, y), None);
+ assert_eq!(at(&mut state, switch_x, y), Some(toggle));
+ // Off the plate, nothing is hovered.
+ assert_eq!(at(&mut state, dx - 50.0, y), None);
+
+ // A slider row: its whole control, readout lane included.
+ while !state.slots.dialog.query.is_empty() {
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Backspace)));
+ }
+ for c in ["g", "e", "o", "m", "e", "t", "r", "y"] {
+ state.dialog_key_input(&typed(c));
+ }
+ let slider = state.slots.dialog.rows.iter().position(|r| r.id == setting_row_id("Geometry Opacity")).expect("a slider row");
+ let mut found = None;
+ let mut y = dy;
+ while y < dy + dh {
+ if at(&mut state, dx + dw - 40.0, y) == Some(slider) {
+ found = Some(y);
+ break;
+ }
+ y += 4.0;
+ }
+ let y = found.expect("the slider is under the pointer somewhere down its column");
+ assert_eq!(at(&mut state, dx + 24.0, y), None, "the label is not the control");
+ // Painting with the control hovered lifts the stamp, and paints.
+ let _ = at(&mut state, dx + dw - 40.0, y);
+ let _ = state.collect_display_list();
+ }
+
#[test]
fn the_palette_lists_the_settings_as_control_rows() {
use crate::dialog::{setting_row_id, Control, SETTINGS};