From 69b9b95d739e111ac16f7a977883b91699454491 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:32:36 +0200 Subject: [PATCH 01/12] fix: hide the tool preview while a click would only dismiss a popup While the properties panel or the context menu is open, a press on the canvas closes it and draws nothing, so the pen's cursor preview promised a stroke that never came. The preview now waits until the popup is gone. --- src/backend/wayland/state/render/ui.rs | 1 + src/input/state/core/modal.rs | 34 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 3a934311..a876bfe1 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -643,6 +643,7 @@ impl WaylandState { self.input_state.ui_visibility.show_tool_preview && self.has_cursor_focus() && !self.cursor_blocked_by_toolbar() + && !self.input_state.canvas_press_dismisses_popup() && matches!( self.input_state.state, DrawingState::Idle | DrawingState::PendingTextClick { .. } diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index a717a844..4261d39b 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -254,6 +254,14 @@ impl InputState { .any(|surface| surface.blocks_canvas_key_repeat() && self.modal_is_open(surface)) } + /// Whether a canvas press would only close an open popup. The properties + /// panel and the context menu both spend the first press outside them on + /// dismissing themselves, so while one is open the canvas draws nothing + /// and a tool's cursor preview would promise a stroke that never comes. + pub(crate) fn canvas_press_dismisses_popup(&self) -> bool { + self.is_properties_panel_open() || self.is_context_menu_open() + } + /// Whether an open surface claims the wheel, so an axis frame must not /// fall through to the canvas tool behind it. /// @@ -393,6 +401,32 @@ mod wheel_tests { assert!(state.is_properties_panel_open()); assert!(!state.modal_owns_wheel()); } + + #[test] + fn a_popup_a_canvas_click_would_only_dismiss_hides_the_tool_preview() { + let measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); + assert!(!state.canvas_press_dismisses_popup()); + let id = state + .boards + .active_frame_mut() + .add_shape(crate::draw::Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + thick: 2.0, + }); + state.set_selection(vec![id]); + + assert!(state.show_properties_panel_with(&measurer)); + assert!(state.canvas_press_dismisses_popup()); + + state.close_properties_panel(); + assert!(!state.canvas_press_dismisses_popup()); + } } #[cfg(test)] From 8da1d4fec259f6e2b0cb811b6a9db67696d85b48 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:45:47 +0200 Subject: [PATCH 02/12] fix: make Move to Front reach the top and add one-step ordering Frame::move_shape read its target as "insert before the shape at to", which can never reach the top of the stack: Move to Front left a shape one below it, and did nothing at all to the shape just beneath the top. The target is now the index the shape ends at, in the move and in its undo. The shape context menu gains Move Forward and Move Backward, which step the selection past its nearest unselected neighbour and move a selected run as one block. The four ordering entries dim when the selection is already as far that way as it goes. --- src/draw/frame/core.rs | 11 +- src/draw/frame/history/frame/apply.rs | 9 +- src/input/state/core/menus/commands.rs | 8 ++ src/input/state/core/menus/entries/shape.rs | 19 ++- src/input/state/core/menus/types.rs | 2 + .../state/core/selection_actions/reorder.rs | 85 ++++++++++++++ src/input/state/tests/menus/context_menu.rs | 46 ++++++++ src/input/state/tests/selection/mod.rs | 1 + src/input/state/tests/selection/reorder.rs | 110 ++++++++++++++++++ 9 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 src/input/state/tests/selection/reorder.rs diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index 8bef0a29..dbaac967 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -240,7 +240,10 @@ impl Frame { Some(shape) } - /// Moves a shape from one index to another. + /// Moves a shape from one index to another; `to` is the index it ends at. + /// + /// It used to be read as "insert before the shape at `to`", which cannot + /// reach the top: Move to Front left a shape one below it. pub fn move_shape(&mut self, from: usize, to: usize) -> Option<()> { if from >= self.shapes.len() || to >= self.shapes.len() { return None; @@ -249,11 +252,7 @@ impl Frame { return Some(()); } let shape = self.shapes.remove(from); - let mut insert_index = to.min(self.shapes.len()); - if from < to && insert_index > 0 { - insert_index -= 1; - } - self.shapes.insert(insert_index, shape); + self.shapes.insert(to, shape); self.bump_shape_order_generation(); Some(()) } diff --git a/src/draw/frame/history/frame/apply.rs b/src/draw/frame/history/frame/apply.rs index eef8342d..422bdcaa 100644 --- a/src/draw/frame/history/frame/apply.rs +++ b/src/draw/frame/history/frame/apply.rs @@ -87,12 +87,11 @@ impl Frame { if index == target { return; } + // `target` is the index the shape ends at, as `Frame::move_shape` + // recorded it. let shape = self.shapes.remove(index); - let mut insert_index = target.min(self.shapes.len()); - if index < insert_index && insert_index > 0 { - insert_index -= 1; - } - self.shapes.insert(insert_index, shape); + let target = target.min(self.shapes.len()); + self.shapes.insert(target, shape); self.bump_shape_order_generation(); } } diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 1d8a8fba..95127b5e 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -113,6 +113,14 @@ impl InputState { self.move_selection_to_front_with(resources.measurer); self.close_context_menu(); } + MenuCommand::MoveForward => { + self.move_selection_forward_with(resources.measurer); + self.close_context_menu(); + } + MenuCommand::MoveBackward => { + self.move_selection_backward_with(resources.measurer); + self.close_context_menu(); + } MenuCommand::MoveToBack => { self.move_selection_to_back_with(resources.measurer); self.close_context_menu(); diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs index 8d85819a..bc862c12 100644 --- a/src/input/state/core/menus/entries/shape.rs +++ b/src/input/state/core/menus/entries/shape.rs @@ -52,19 +52,34 @@ impl InputState { false, Some(MenuCommand::Duplicate), )); + // Dimmed when the selection is already as far that way as it goes. + let can_raise = self.selection_can_step(true); + let can_lower = self.selection_can_step(false); entries.push( ContextMenuEntry::new( "Move to Front", self.shortcut_for_action(Action::MoveSelectionToFront), - false, + !can_raise, Some(MenuCommand::MoveToFront), ) .with_separator(), ); + entries.push(ContextMenuEntry::new( + "Move Forward", + None::, + !can_raise, + Some(MenuCommand::MoveForward), + )); + entries.push(ContextMenuEntry::new( + "Move Backward", + None::, + !can_lower, + Some(MenuCommand::MoveBackward), + )); entries.push(ContextMenuEntry::new( "Move to Back", self.shortcut_for_action(Action::MoveSelectionToBack), - false, + !can_lower, Some(MenuCommand::MoveToBack), )); entries.push(ContextMenuEntry::new( diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index 7e5a76f1..6e7bd05d 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -64,6 +64,8 @@ pub enum MenuCommand { Duplicate, SelectHoveredShape, MoveToFront, + MoveForward, + MoveBackward, MoveToBack, Lock, Unlock, diff --git a/src/input/state/core/selection_actions/reorder.rs b/src/input/state/core/selection_actions/reorder.rs index 03cecdca..c6bee03a 100644 --- a/src/input/state/core/selection_actions/reorder.rs +++ b/src/input/state/core/selection_actions/reorder.rs @@ -11,6 +11,91 @@ impl InputState { self.reorder_selection(measurer, false) } + /// Moves every selected shape one step up, past the nearest unselected + /// shape above it. A run of selected shapes moves as a block, so their + /// order among themselves stays as it was. + pub(crate) fn move_selection_forward_with(&mut self, measurer: &TextMeasurer) -> bool { + self.step_selection(measurer, true) + } + + /// Moves every selected shape one step down; see + /// [`Self::move_selection_forward_with`]. + pub(crate) fn move_selection_backward_with(&mut self, measurer: &TextMeasurer) -> bool { + self.step_selection(measurer, false) + } + + /// Whether some selected shape has an unselected shape above it (or below + /// it, for `forward == false`), so a step that way would move something. + pub(crate) fn selection_can_step(&self, forward: bool) -> bool { + let selected = self.selected_shape_ids(); + let shapes = &self.boards.active_frame().shapes; + let is_selected = |index: usize| selected.contains(&shapes[index].id); + (0..shapes.len()).any(|index| { + is_selected(index) + && if forward { + (index + 1..shapes.len()).any(|above| !is_selected(above)) + } else { + (0..index).any(|below| !is_selected(below)) + } + }) + } + + fn step_selection(&mut self, measurer: &TextMeasurer, forward: bool) -> bool { + let selected: Vec<_> = self.selected_shape_ids().to_vec(); + if selected.is_empty() { + return false; + } + + let len = self.boards.active_frame().shapes.len(); + // Walk against the direction of travel, so a shape that just moved is + // never visited again and a selected block shifts as one. + let indices: Vec = if forward { + (0..len.saturating_sub(1)).rev().collect() + } else { + (1..len).collect() + }; + let mut actions = Vec::new(); + for from in indices { + let to = if forward { from + 1 } else { from - 1 }; + let (moving, neighbour) = { + let shapes = &self.boards.active_frame().shapes; + (shapes[from].id, shapes[to].id) + }; + if !selected.contains(&moving) || selected.contains(&neighbour) { + continue; + } + if self + .boards + .active_frame_mut() + .move_shape(from, to) + .is_some() + { + actions.push(UndoAction::Reorder { + shape_id: moving, + from, + to, + }); + for id in [moving, neighbour] { + if let Some(shape) = self.boards.active_frame().shape(id) { + self.dirty_tracker.mark_shape_with(&shape.shape, measurer); + } + self.invalidate_hit_cache_for_with(measurer, id); + } + } + } + + if actions.is_empty() { + return false; + } + + self.boards.active_frame_mut().push_undo_action( + UndoAction::Compound { actions }, + self.history_limits.undo_stack_limit(), + ); + self.mark_session_dirty(); + true + } + fn reorder_selection(&mut self, measurer: &TextMeasurer, to_front: bool) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index b458e946..cec4ff62 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -1038,3 +1038,49 @@ fn open_board_picker_command_closes_context_menu_and_opens_picker() { assert!(!state.is_context_menu_open()); assert!(state.is_board_picker_open()); } + +#[test] +fn shape_menu_orders_by_steps_and_dims_the_way_the_shape_cannot_go() { + let mut state = create_test_input_state(); + let rect = |x| Shape::Rect { + x, + y: 0, + w: 10, + h: 10, + fill: false, + color: Color::new(1.0, 0.0, 0.0, 1.0), + thick: 2.0, + }; + let bottom = state.boards.active_frame_mut().add_shape(rect(0)); + let _top = state.boards.active_frame_mut().add_shape(rect(20)); + state.set_selection(vec![bottom]); + state.open_context_menu((0, 0), vec![bottom], ContextMenuKind::Shape, Some(bottom)); + + let entries = state.context_menu_entries(); + let disabled = |label: &str| { + entries + .iter() + .find(|entry| entry.label == label) + .unwrap_or_else(|| panic!("{label} entry")) + .disabled + }; + let labels: Vec<_> = entries.iter().map(|entry| entry.label.as_str()).collect(); + let front = labels + .iter() + .position(|label| *label == "Move to Front") + .unwrap(); + assert_eq!( + &labels[front..front + 4], + [ + "Move to Front", + "Move Forward", + "Move Backward", + "Move to Back" + ] + ); + assert!(!disabled("Move to Front") && !disabled("Move Forward")); + assert!( + disabled("Move Backward") && disabled("Move to Back"), + "the bottom shape cannot go lower" + ); +} diff --git a/src/input/state/tests/selection/mod.rs b/src/input/state/tests/selection/mod.rs index cf2a84f6..4e4b5865 100644 --- a/src/input/state/tests/selection/mod.rs +++ b/src/input/state/tests/selection/mod.rs @@ -5,3 +5,4 @@ mod actions; mod damage; mod deletion; mod duplicate; +mod reorder; diff --git a/src/input/state/tests/selection/reorder.rs b/src/input/state/tests/selection/reorder.rs new file mode 100644 index 00000000..743a3aa4 --- /dev/null +++ b/src/input/state/tests/selection/reorder.rs @@ -0,0 +1,110 @@ +use super::*; +use crate::draw::{ShapeId, TextMeasurer}; + +fn add(state: &mut InputState, x: i32) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y: 0, + w: 10, + h: 10, + fill: false, + color: Color::new(1.0, 0.0, 0.0, 1.0), + thick: 2.0, + }) +} + +fn order(state: &InputState) -> Vec { + state + .boards + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect() +} + +fn stack(count: i32) -> (InputState, Vec) { + let mut state = create_test_input_state(); + let ids = (0..count) + .map(|index| add(&mut state, index * 20)) + .collect(); + (state, ids) +} + +#[test] +fn move_to_front_and_back_reach_the_ends_of_the_stack() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(3); + + state.set_selection(vec![ids[0]]); + assert!(state.move_selection_to_front_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[2], ids[0]]); + + state.set_selection(vec![ids[2]]); + assert!(state.move_selection_to_back_with(&measurer)); + assert_eq!(order(&state), vec![ids[2], ids[1], ids[0]]); + assert!( + !state.move_selection_to_back_with(&measurer), + "already at the back" + ); + + // From one below the top, the old reading could not move it at all. + state.set_selection(vec![ids[1]]); + assert!(state.move_selection_to_front_with(&measurer)); + assert_eq!(order(&state), vec![ids[2], ids[0], ids[1]]); +} + +#[test] +fn forward_and_backward_step_one_shape_past_its_neighbour() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(3); + state.set_selection(vec![ids[0]]); + + assert!(state.selection_can_step(true)); + assert!(!state.selection_can_step(false)); + assert!(state.move_selection_forward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[0], ids[2]]); + assert!(state.move_selection_forward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[2], ids[0]]); + assert!(!state.selection_can_step(true)); + assert!( + !state.move_selection_forward_with(&measurer), + "already on top" + ); + + assert!(state.move_selection_backward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[0], ids[2]]); +} + +#[test] +fn a_selected_block_steps_as_one_and_keeps_its_own_order() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(4); + state.set_selection(vec![ids[0], ids[1]]); + + assert!(state.move_selection_forward_with(&measurer)); + + assert_eq!(order(&state), vec![ids[2], ids[0], ids[1], ids[3]]); +} + +#[test] +fn a_step_is_one_undo_entry_that_undoes_and_redoes() { + let measurer = TextMeasurer::default(); + let resources_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &resources_engine, + }; + let (mut state, ids) = stack(4); + let before = order(&state); + state.set_selection(vec![ids[0], ids[2]]); + + assert!(state.move_selection_forward_with(&measurer)); + let after = order(&state); + assert_eq!(after, vec![ids[1], ids[0], ids[3], ids[2]]); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(order(&state), before); + state.handle_action_with_resources(resources, Action::Redo); + assert_eq!(order(&state), after); +} From 12b147b91a21eb24c693c32af59bb6b835dfb9ab Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:58:37 +0200 Subject: [PATCH 03/12] feat: order, duplicate, and delete from the properties panel The panel gains an actions area under its rows: four ordering buttons (to back, back one, forward one, to front) and Duplicate and Delete, run through the same selection edits as the context menu. Buttons that would do nothing dim: ordering at the end of the stack, Duplicate and Delete for a locked selection. Duplicating keeps the panel open on the copies, and deleting closes it with nothing left to show. The area has a fixed height the layout fits around, so short screens still fall back to columns or scrolling rows. --- README.md | 2 +- src/input/state/core/mod.rs | 6 +- src/input/state/core/properties/apply.rs | 51 +++++++- src/input/state/core/properties/metrics.rs | 12 ++ src/input/state/core/properties/mod.rs | 6 +- src/input/state/core/properties/panel.rs | 14 ++- .../core/properties/panel_layout/geometry.rs | 52 +++++++- .../properties/panel_layout/interaction.rs | 5 + .../core/properties/panel_layout/layout.rs | 31 ++--- .../core/properties/panel_layout/tests.rs | 21 +++- src/input/state/core/properties/state.rs | 5 + src/input/state/core/properties/types.rs | 46 ++++++- src/input/state/mod.rs | 6 +- .../state/tests/properties_panel_controls.rs | 91 ++++++++++++++ src/ui/properties_panel.rs | 5 + src/ui/properties_panel/actions.rs | 113 ++++++++++++++++++ 16 files changed, 429 insertions(+), 37 deletions(-) create mode 100644 src/ui/properties_panel/actions.rs diff --git a/README.md b/README.md index 06757692..41a65180 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) -- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, and a lock toggle; scroll over a row to step it, and hover the title for the shape's ID and creation time +- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index ab5f325f..2e142051 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -126,9 +126,9 @@ pub use menus::{ pub use menus::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY}; pub(crate) use properties::metrics as properties_panel_metrics; pub use properties::{ - PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, - SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, + PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, + SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, }; pub use radial_menu::{ COMPASS_SLICES as RADIAL_COMPASS_SLICES, CompassDir, RADIAL_PAINT_DELAY, RadialMenuLayout, diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 8e281867..ed48512b 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use super::types::{ - PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry, SelectionPropertyKind, - SelectionPropertyValue, + PanelAction, PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry, + SelectionPropertyKind, SelectionPropertyValue, }; use crate::draw::{ArrowStyle, Color, Shape, TextMeasurer}; @@ -17,12 +17,17 @@ impl InputState { if hit == PropertiesPanelHit::Lock { return self.toggle_properties_panel_lock_with(measurer); } + if let PropertiesPanelHit::Action(action) = hit { + return self.run_properties_panel_action_with(measurer, action); + } let Some(entry) = hit.row().and_then(|row| self.enabled_properties_entry(row)) else { return false; }; let changed = match hit { - PropertiesPanelHit::Title | PropertiesPanelHit::Lock => false, + PropertiesPanelHit::Title + | PropertiesPanelHit::Lock + | PropertiesPanelHit::Action(_) => false, PropertiesPanelHit::Swatch { index, .. } => { let Some(color) = self .properties @@ -131,6 +136,46 @@ impl InputState { self.apply_selection_arrow_style_value(measurer, style) } + /// Runs an actions-area button through the same selection edits as the + /// context menu and the keyboard. A delete empties the selection, and the + /// refresh then closes the panel with nothing left to show. + fn run_properties_panel_action_with( + &mut self, + measurer: &TextMeasurer, + action: PanelAction, + ) -> bool { + let enabled = self + .properties + .panel + .as_ref() + .is_some_and(|panel| panel.actions.enabled(action)); + if !enabled { + return false; + } + + let changed = match action { + PanelAction::ToBack => self.move_selection_to_back_with(measurer), + PanelAction::Backward => self.move_selection_backward_with(measurer), + PanelAction::Forward => self.move_selection_forward_with(measurer), + PanelAction::ToFront => self.move_selection_to_front_with(measurer), + PanelAction::Duplicate => { + // Duplicating selects the copies, and any selection change + // closes the panel; reopen it on the copies it was asked for. + let duplicated = self.duplicate_selection_with(measurer); + if duplicated && !self.is_properties_panel_open() { + let _ = self.show_properties_panel_with(measurer); + } + duplicated + } + PanelAction::Delete => self.delete_selection_with(measurer), + }; + + if changed && self.is_properties_panel_open() { + self.refresh_properties_panel_with(measurer); + } + changed + } + /// Locks every selected shape, or unlocks them all once every one is /// locked. Locked shapes refuse edits, so this is also how the panel's /// disabled rows come back. diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs index e81d2f15..443681ad 100644 --- a/src/input/state/core/properties/metrics.rs +++ b/src/input/state/core/properties/metrics.rs @@ -74,6 +74,18 @@ pub(crate) const LOCK_SIZE: f64 = 26.0; /// The lock button's inset from the panel's top-right corner. pub(crate) const LOCK_INSET: f64 = 8.0; +/// The actions area: a divider, the ordering row, and the Duplicate/Delete +/// row under it. +pub(crate) const ACTIONS_TOP_GAP: f64 = 12.0; +pub(crate) const ACTION_BUTTON_HEIGHT: f64 = 28.0; +pub(crate) const ACTION_ROW_GAP: f64 = 8.0; +pub(crate) const ACTION_BUTTON_GAP: f64 = 6.0; +pub(crate) const ACTIONS_BOTTOM: f64 = 6.0; +pub(crate) const ACTIONS_HEIGHT: f64 = + ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 2.0 + ACTION_ROW_GAP + ACTIONS_BOTTOM; +/// The "Order" label's column, where both rows of buttons start. +pub(crate) const ACTIONS_LABEL_WIDTH: f64 = 60.0; + pub(crate) const FOOTER_HEIGHT: f64 = 30.0; /// Height of the empty-state line when the selection has no properties. pub(crate) const EMPTY_HEIGHT: f64 = 30.0; diff --git a/src/input/state/core/properties/mod.rs b/src/input/state/core/properties/mod.rs index 20ae5f27..b9aa0daf 100644 --- a/src/input/state/core/properties/mod.rs +++ b/src/input/state/core/properties/mod.rs @@ -11,7 +11,7 @@ mod utils; pub use state::PropertiesPanelState; pub use types::{ - PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, - SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, + PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, + SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, }; diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 6939546a..588fb9db 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -2,8 +2,8 @@ use super::super::base::InputState; use super::metrics::MAX_SWATCHES; use super::panel_layout::selection_panel_anchor; use super::types::{ - PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, SelectionPropertyEntry, - SelectionPropertyValue, ShapePropertiesPanel, + PanelActions, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, + SelectionPropertyEntry, SelectionPropertyValue, ShapePropertiesPanel, }; use super::utils::format_timestamp; use crate::draw::{Color, TextMeasurer}; @@ -23,6 +23,7 @@ struct PanelContents { anchor_rect: Option, entries: Vec, swatches: Vec, + actions: PanelActions, preview_color: Option, multiple_selection: bool, } @@ -98,6 +99,7 @@ impl InputState { anchor_rect: contents.anchor_rect, entries: contents.entries, swatches: contents.swatches, + actions: contents.actions, preview_color: contents.preview_color, hover: None, pressed: None, @@ -128,6 +130,7 @@ impl InputState { panel.anchor_rect = contents.anchor_rect; panel.entries = contents.entries; panel.swatches = contents.swatches; + panel.actions = contents.actions; panel.preview_color = contents.preview_color; panel.multiple_selection = contents.multiple_selection; @@ -241,6 +244,12 @@ impl InputState { ) }; + let actions = PanelActions { + can_raise: self.selection_can_step(true), + can_lower: self.selection_can_step(false), + can_edit: lock != PropertiesPanelLock::Locked, + }; + Some(PanelContents { title, subtitle, @@ -250,6 +259,7 @@ impl InputState { anchor_rect, entries, swatches, + actions, preview_color, multiple_selection: ids.len() > 1, }) diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index ea4c015f..5102802f 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -3,13 +3,14 @@ //! is clickable exactly where it is drawn. use super::super::metrics::{ + ACTION_BUTTON_GAP, ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_LABEL_WIDTH, ACTIONS_TOP_GAP, BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING, PREVIEW_GAP, PREVIEW_HEIGHT, PREVIEW_WIDTH, ROW_HEIGHT, ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP, STYLE_BUTTON_HEIGHT, SWATCH_GAP, SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, TITLE_FONT, }; use super::super::types::{ - PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, + PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, }; @@ -152,6 +153,34 @@ impl PropertiesPanelLayout { .is_none_or(|scroll| y >= self.rows_top && y < scroll.viewport_bottom) } + /// The actions area's buttons: the four ordering buttons in one row and + /// Duplicate and Delete under them, both starting after the "Order" + /// label's column and spanning the full content width. + pub fn action_buttons(&self) -> Vec<(PanelAction, PanelRect)> { + let left = self.content_x() + ACTIONS_LABEL_WIDTH; + let width = self.content_right() - left; + let row = |top: f64, actions: &[PanelAction]| { + let count = actions.len() as f64; + let button = (width - ACTION_BUTTON_GAP * (count - 1.0)) / count; + actions + .iter() + .enumerate() + .map(|(index, action)| { + let x = left + index as f64 * (button + ACTION_BUTTON_GAP); + ( + *action, + PanelRect::new(x, top, button, ACTION_BUTTON_HEIGHT), + ) + }) + .collect::>() + }; + let order_top = self.actions_top + ACTIONS_TOP_GAP; + let edit_top = order_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; + let mut buttons = row(order_top, &PanelAction::ORDER); + buttons.extend(row(edit_top, &PanelAction::EDIT)); + buttons + } + /// Left edge of the content of `column`. pub fn column_x(&self, column: usize) -> f64 { self.content_x() + column as f64 * (self.column_width + COLUMN_SPACING) @@ -319,6 +348,13 @@ impl PropertiesPanelLayout { if self.title_rect().contains(x, y) { return Some(PropertiesPanelHit::Title); } + if y >= self.actions_top { + return self + .action_buttons() + .into_iter() + .find(|(_, rect)| rect.contains(x, y)) + .map(|(action, _)| PropertiesPanelHit::Action(action)); + } // Rows scrolled out of the viewport are clipped, so they take no clicks. if !self.rows_visible_at(y) { return None; @@ -391,6 +427,13 @@ impl PropertiesPanelLayout { match hit { PropertiesPanelHit::Title => return Some(self.title_rect()), PropertiesPanelHit::Lock => return Some(self.lock), + PropertiesPanelHit::Action(action) => { + return self + .action_buttons() + .into_iter() + .find(|(candidate, _)| *candidate == action) + .map(|(_, rect)| rect); + } _ => {} } let row = self.rows(panel).into_iter().nth(hit.row()?)?; @@ -485,6 +528,13 @@ impl ShapePropertiesPanel { self.swatches.get(index).map(|swatch| swatch.label.clone()) } PropertiesPanelHit::MoreColors(_) => Some("More colors…".to_string()), + PropertiesPanelHit::Action(action) => match action { + PanelAction::ToBack => Some("Send to back".to_string()), + PanelAction::Backward => Some("Send backward".to_string()), + PanelAction::Forward => Some("Bring forward".to_string()), + PanelAction::ToFront => Some("Bring to front".to_string()), + PanelAction::Duplicate | PanelAction::Delete => None, + }, _ => None, } } diff --git a/src/input/state/core/properties/panel_layout/interaction.rs b/src/input/state/core/properties/panel_layout/interaction.rs index 1ff21ca0..02f04639 100644 --- a/src/input/state/core/properties/panel_layout/interaction.rs +++ b/src/input/state/core/properties/panel_layout/interaction.rs @@ -22,6 +22,11 @@ impl InputState { ) -> Option { let hit = self.properties_panel_hit_at(x, y)?; let panel = self.properties.panel.as_ref()?; + if let PropertiesPanelHit::Action(action) = hit + && !panel.actions.enabled(action) + { + return None; + } match hit.row() { Some(row) if panel.entries.get(row).is_none_or(|entry| entry.disabled) => None, _ => Some(hit), diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index 37e0b7dc..8abc8622 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -3,16 +3,17 @@ use cairo::FontWeight; use super::super::super::base::InputState; use super::super::metrics::{ - BODY_FONT, COLUMN_GAP, COLUMN_SPACING, EMPTY_HEIGHT, FOOTER_HEIGHT, HEADER_GAP, LOCK_INSET, - LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, PREVIEW_GAP, PREVIEW_WIDTH, - ROW_HEIGHT, ROWS_GAP, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, SEGMENT_TEXT_PADDING, - STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING, STYLE_BUTTON_GAP, - STYLE_BUTTON_MIN_WIDTH, SUBTITLE_FONT, SUBTITLE_STEP, SWITCH_VALUE_GAP, SWITCH_WIDTH, - TITLE_FONT, TOOLTIP_FONT, TOOLTIP_GAP, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y, VALUE_FONT, - text_style, + ACTIONS_HEIGHT, BODY_FONT, COLUMN_GAP, COLUMN_SPACING, EMPTY_HEIGHT, FOOTER_HEIGHT, HEADER_GAP, + LOCK_INSET, LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, PREVIEW_GAP, + PREVIEW_WIDTH, ROW_HEIGHT, ROWS_GAP, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, + SEGMENT_TEXT_PADDING, STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING, + STYLE_BUTTON_GAP, STYLE_BUTTON_MIN_WIDTH, SUBTITLE_FONT, SUBTITLE_STEP, SWITCH_VALUE_GAP, + SWITCH_WIDTH, TITLE_FONT, TOOLTIP_FONT, TOOLTIP_GAP, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y, + VALUE_FONT, text_style, }; use super::super::types::{ - PanelRect, PanelScroll, PropertiesPanelLayout, SelectionPropertyValue, ShapePropertiesPanel, + PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, SelectionPropertyValue, + ShapePropertiesPanel, }; use super::geometry::{ balanced_column_budget, column_slots, row_height, shows_thickness_preview, swatch_grid_width, @@ -218,7 +219,7 @@ impl InputState { } else { 0.0 }; - let panel_height = (rows_top + fit.height + tail + PADDING_BOTTOM).ceil(); + let panel_height = (rows_top + fit.height + ACTIONS_HEIGHT + tail + PADDING_BOTTOM).ceil(); let screen_w = screen_width as f64; let screen_h = screen_height as f64; @@ -250,7 +251,8 @@ impl InputState { ), divider_y: origin_y + divider, rows_top: origin_y + rows_top, - footer_top: has_footer.then_some(origin_y + rows_top + fit.height), + actions_top: origin_y + rows_top + fit.height, + footer_top: has_footer.then_some(origin_y + rows_top + fit.height + ACTIONS_HEIGHT), stepper_value_width: measured.stepper_value_width, head_segment_width: measured.head_segment_width, column_width, @@ -333,7 +335,7 @@ impl InputState { let x = (center_x - width / 2.0).clamp(PANEL_MARGIN, max_x); let below = anchor.bottom() + TOOLTIP_GAP; let above = anchor.y - TOOLTIP_GAP - height; - let header = hit.row().is_none(); + let header = matches!(hit, PropertiesPanelHit::Title | PropertiesPanelHit::Lock); let fits_below = below + height <= screen_h - PANEL_MARGIN; let fits_above = above >= PANEL_MARGIN; let y = if (header && fits_above) || !fits_below { @@ -357,7 +359,8 @@ struct RowFit { scrolled_content: Option, } -/// Fits the rows under a header of `rows_top` onto the screen. In order: one +/// Fits the rows, between a header of `rows_top` and the fixed actions area, +/// onto the screen. In order: one /// column with the keyboard hints; one without them; as few even columns as /// fit both the height and the width; and, when no arrangement fits, one /// column scrolling inside the panel. Only the last gives up the wheel for @@ -386,10 +389,10 @@ fn fit_rows( scrolled_content: None, }; - if rows_top + total + FOOTER_HEIGHT + PADDING_BOTTOM <= available_h { + if rows_top + total + ACTIONS_HEIGHT + FOOTER_HEIGHT + PADDING_BOTTOM <= available_h { return single(true); } - let band = available_h - rows_top - PADDING_BOTTOM; + let band = available_h - rows_top - ACTIONS_HEIGHT - PADDING_BOTTOM; if total <= band { return single(false); } diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs index eb909029..4f5abe62 100644 --- a/src/input/state/core/properties/panel_layout/tests.rs +++ b/src/input/state/core/properties/panel_layout/tests.rs @@ -1,4 +1,4 @@ -use super::super::metrics::{FOOTER_HEIGHT, PADDING_BOTTOM}; +use super::super::metrics::{ACTIONS_HEIGHT, FOOTER_HEIGHT, PADDING_BOTTOM}; use super::super::types::{ PanelRect, PropertiesPanelHit, PropertiesRowControl, PropertiesRowGeometry, }; @@ -155,8 +155,9 @@ fn rows_stack_from_the_divider_to_the_footer() { ); top = row.rect.bottom(); } + assert_eq!(layout.actions_top, top, "the actions sit under the rows"); let footer_top = layout.footer_top.expect("footer"); - assert_eq!(footer_top, top); + assert_eq!(footer_top, top + ACTIONS_HEIGHT); assert_eq!( layout.origin_y + layout.height, (footer_top + FOOTER_HEIGHT + PADDING_BOTTOM).ceil() @@ -354,7 +355,13 @@ fn a_selection_taller_than_the_screen_flows_into_columns() { spotlight(&mut state), ]; open_panel(ids, &mut state); + lay_out(&mut state, (SCREEN.0, 2000)); let single = *state.properties_panel_layout().expect("layout"); + assert_eq!( + single.column_budget, + f64::INFINITY, + "one column on a tall screen" + ); lay_out(&mut state, (SCREEN.0, 480)); @@ -442,15 +449,17 @@ fn when_columns_would_leave_the_screen_the_rows_scroll_instead() { let first = assert_visible_rows_reachable(&state, (480.0, 360.0)); assert!(first.contains(&0)); - // A row clipped out of the viewport takes no clicks. + // A row clipped out of the viewport takes no clicks; what is drawn + // there (the actions, or nothing) does. let hidden = rows(&state) .into_iter() .find(|row| row.rect.y >= layout.rows_viewport().bottom()) .expect("a row below the viewport"); let (hx, hy) = hidden.rect.center(); - assert_eq!( - layout.hit_at(state.properties_panel().unwrap(), hx, hy), - None + let hit = layout.hit_at(state.properties_panel().unwrap(), hx, hy); + assert!( + hit.and_then(PropertiesPanelHit::row).is_none(), + "{hit:?} reached a clipped row" ); // The wheel scrolls rather than stepping a row, down to the last row. diff --git a/src/input/state/core/properties/state.rs b/src/input/state/core/properties/state.rs index 956c0790..c840658a 100644 --- a/src/input/state/core/properties/state.rs +++ b/src/input/state/core/properties/state.rs @@ -75,6 +75,11 @@ mod tests { anchor_rect: None, entries: Vec::new(), swatches: Vec::new(), + actions: super::super::types::PanelActions { + can_raise: false, + can_lower: false, + can_edit: false, + }, preview_color: None, hover: None, pressed: None, diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 6c6b534a..2c64e75d 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -77,12 +77,53 @@ pub enum PropertiesPanelLock { Locked, } +/// A button in the panel's actions area. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelAction { + ToBack, + Backward, + Forward, + ToFront, + Duplicate, + Delete, +} + +impl PanelAction { + /// The ordering buttons, bottom of the stack to top. + pub const ORDER: [Self; 4] = [Self::ToBack, Self::Backward, Self::Forward, Self::ToFront]; + /// The buttons under them. + pub const EDIT: [Self; 2] = [Self::Duplicate, Self::Delete]; +} + +/// Which actions can do anything for the current selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PanelActions { + /// Some selected shape has an unselected one above it. + pub can_raise: bool, + /// Some selected shape has an unselected one below it. + pub can_lower: bool, + /// Some selected shape is unlocked, so duplicating or deleting has + /// something to work on. + pub can_edit: bool, +} + +impl PanelActions { + pub fn enabled(&self, action: PanelAction) -> bool { + match action { + PanelAction::ToBack | PanelAction::Backward => self.can_lower, + PanelAction::Forward | PanelAction::ToFront => self.can_raise, + PanelAction::Duplicate | PanelAction::Delete => self.can_edit, + } + } +} + /// The part of the properties panel under the pointer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PropertiesPanelHit { /// The title, which carries the shape details as a tooltip. Title, Lock, + Action(PanelAction), /// A row away from its controls. Row(usize), Swatch { @@ -108,7 +149,7 @@ impl PropertiesPanelHit { /// The row this part belongs to; `None` for the header. pub fn row(self) -> Option { match self { - Self::Title | Self::Lock => None, + Self::Title | Self::Lock | Self::Action(_) => None, Self::Row(row) | Self::Swatch { row, .. } | Self::MoreColors(row) @@ -220,6 +261,8 @@ pub struct PropertiesPanelLayout { pub lock: PanelRect, pub divider_y: f64, pub rows_top: f64, + /// Top of the actions area (ordering, Duplicate, Delete), under the rows. + pub actions_top: f64, /// Top of the keyboard hint strip; `None` when the panel has no rows. pub footer_top: Option, /// Width of the readout between a stepper's − and + buttons. @@ -251,6 +294,7 @@ pub struct ShapePropertiesPanel { pub anchor_rect: Option, pub entries: Vec, pub swatches: Vec, + pub actions: PanelActions, /// The selection's shared color, for the thickness preview stroke. pub preview_color: Option, pub hover: Option, diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 11042087..4f6cd14b 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -14,9 +14,9 @@ pub(crate) use core::{ }; pub(crate) use core::{InputEffect, InputEffectDrain}; pub(crate) use core::{ - PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, ShapePropertiesPanel, - properties_panel_metrics, + PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, + ShapePropertiesPanel, properties_panel_metrics, }; pub(in crate::input::state) use spotlight::SpotlightWheelGesture; pub(crate) use spotlight::{SpotlightFrameRegions, SpotlightWheelClaim, SpotlightWheelOutcome}; diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index b1b140f7..7af974bb 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -629,3 +629,94 @@ fn stepping_a_marker_skips_the_opacity_variant_it_cannot_take() { other => panic!("expected marker, got {other:?}"), } } + +fn stack_order(state: &InputState) -> Vec { + state + .boards + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect() +} + +#[test] +fn order_buttons_move_the_shape_and_dim_at_the_ends_of_the_stack() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let bottom = add_rect(&mut state, PALETTE_RED, false); + let middle = add_rect(&mut state, PALETTE_GREEN, false); + let top = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![bottom]); + let to_back = point(&state, PropertiesPanelHit::Action(PanelAction::ToBack)); + assert_eq!( + state.properties_panel_active_hit_at(to_back.0, to_back.1), + None, + "the bottom shape cannot go lower" + ); + + click(&mut state, PropertiesPanelHit::Action(PanelAction::Forward)); + assert_eq!(stack_order(&state), vec![middle, bottom, top]); + click(&mut state, PropertiesPanelHit::Action(PanelAction::ToFront)); + assert_eq!(stack_order(&state), vec![middle, top, bottom]); + + let panel = state.properties_panel().expect("panel stays open"); + assert!(!panel.actions.enabled(PanelAction::ToFront)); + assert!(panel.actions.enabled(PanelAction::Backward)); + assert!( + panel + .subtitle + .as_deref() + .unwrap() + .starts_with("Layer 3 of 3"), + "the header follows the move" + ); +} + +#[test] +fn duplicate_keeps_the_panel_on_the_copy_and_delete_closes_it() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let original = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![original]); + + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Duplicate), + ); + assert_eq!(state.boards.active_frame().shapes.len(), 2); + let copy = state.selected_shape_ids().to_vec(); + assert_eq!(copy.len(), 1); + assert_ne!(copy[0], original); + assert!(state.is_properties_panel_open()); + + click(&mut state, PropertiesPanelHit::Action(PanelAction::Delete)); + assert_eq!(stack_order(&state), vec![original]); + lay_out(&mut state); + assert!( + !state.is_properties_panel_open(), + "nothing selected is left to show" + ); +} + +#[test] +fn a_locked_selection_can_be_reordered_but_not_duplicated_or_deleted() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let locked = add_rect(&mut state, PALETTE_RED, false); + let _above = add_rect(&mut state, PALETTE_GREEN, false); + let index = state.boards.active_frame().find_index(locked).unwrap(); + state.boards.active_frame_mut().shapes[index].locked = true; + open(&mut state, vec![locked]); + + let actions = state.properties_panel().unwrap().actions; + assert!(!actions.enabled(PanelAction::Duplicate)); + assert!(!actions.enabled(PanelAction::Delete)); + assert!(actions.enabled(PanelAction::Forward)); + let delete = point(&state, PropertiesPanelHit::Action(PanelAction::Delete)); + click_at(&mut state, delete, delete); + assert_eq!(state.boards.active_frame().shapes.len(), 2); +} diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs index 76414a5c..7c11d401 100644 --- a/src/ui/properties_panel.rs +++ b/src/ui/properties_panel.rs @@ -26,8 +26,11 @@ use crate::ui::theme::overlay::{ use crate::ui::theme::{Rgba, set_color, with_alpha}; use crate::ui_text::UiTextEngine; +mod actions; mod controls; +use actions::draw_actions; + use controls::{ ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_stepper, draw_swatches, draw_switch, @@ -162,6 +165,8 @@ pub(crate) fn render_properties_panel_with_engine( } } + draw_actions(engine, ctx, panel, layout); + if let Some(footer_top) = layout.footer_top { draw_footer(engine, ctx, layout, footer_top); } diff --git a/src/ui/properties_panel/actions.rs b/src/ui/properties_panel/actions.rs new file mode 100644 index 00000000..1a53b69a --- /dev/null +++ b/src/ui/properties_panel/actions.rs @@ -0,0 +1,113 @@ +//! The panel's actions area: the four ordering buttons, and Duplicate and +//! Delete under them. + +use cairo::FontWeight; + +use crate::input::state::properties_panel_metrics::{ + ACTION_BUTTON_HEIGHT, ACTIONS_TOP_GAP, BODY_FONT, VALUE_FONT, text_style, +}; +use crate::input::state::{ + PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, ShapePropertiesPanel, +}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::overlay::{ + BG_HOVER, BG_HOVER_WASH, DIVIDER_LIGHT, RADIUS_STD, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, +}; +use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba, set_color, with_alpha}; +use crate::ui_text::UiTextEngine; + +/// Delete's label, in the destructive red the toolbar's Clear uses. +const DELETE_TEXT: Rgba = rgba(DESTRUCTIVE_RGB, 1.0); +const GLYPH_WIDTH: f64 = 1.6; + +pub(super) fn draw_actions( + engine: &UiTextEngine, + ctx: &cairo::Context, + panel: &ShapePropertiesPanel, + layout: &PropertiesPanelLayout, +) { + set_color(ctx, with_alpha(DIVIDER_LIGHT, 0.4)); + ctx.set_line_width(1.0); + let divider = layout.actions_top + ACTIONS_TOP_GAP / 2.0 - 0.5; + ctx.move_to(layout.content_x(), divider); + ctx.line_to(layout.content_right(), divider); + let _ = ctx.stroke(); + + set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline( + ctx, + text_style(BODY_FONT, FontWeight::Normal), + "Order", + layout.content_x(), + layout.actions_top + ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT / 2.0 + BODY_FONT * 0.35, + None, + ); + + for (action, rect) in layout.action_buttons() { + let enabled = panel.actions.enabled(action); + let hovered = enabled && panel.hover == Some(PropertiesPanelHit::Action(action)); + set_color(ctx, if hovered { BG_HOVER } else { BG_HOVER_WASH }); + draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD); + let _ = ctx.fill(); + + let ink = match (enabled, action) { + (false, _) => TEXT_DISABLED, + (true, PanelAction::Delete) => DELETE_TEXT, + (true, _) if hovered => TEXT_PRIMARY, + (true, _) => TEXT_SECONDARY, + }; + set_color(ctx, ink); + match action { + PanelAction::Duplicate => draw_label(engine, ctx, rect, "Duplicate"), + PanelAction::Delete => draw_label(engine, ctx, rect, "Delete"), + order => draw_order_glyph(ctx, rect, order), + } + } +} + +fn draw_label(engine: &UiTextEngine, ctx: &cairo::Context, rect: PanelRect, label: &str) { + let style = text_style(VALUE_FONT, FontWeight::Normal); + let width = engine + .layout(ctx, style, label, None) + .ink_extents() + .x_advance(); + let (cx, cy) = rect.center(); + engine.draw_baseline( + ctx, + style, + label, + cx - width / 2.0, + cy + VALUE_FONT * 0.35, + None, + ); +} + +/// An arrow pointing the way the shape moves; to back and to front add a bar +/// at the end of the stack it goes to. +fn draw_order_glyph(ctx: &cairo::Context, rect: PanelRect, action: PanelAction) { + let (cx, cy) = rect.center(); + let up = matches!(action, PanelAction::Forward | PanelAction::ToFront); + let to_end = matches!(action, PanelAction::ToBack | PanelAction::ToFront); + let (tail, tip) = match (up, to_end) { + (true, true) => (cy + 6.0, cy - 3.0), + (true, false) => (cy + 6.0, cy - 6.0), + (false, true) => (cy - 6.0, cy + 3.0), + (false, false) => (cy - 6.0, cy + 6.0), + }; + let back = if up { tip + 4.0 } else { tip - 4.0 }; + + ctx.set_line_width(GLYPH_WIDTH); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + ctx.move_to(cx, tail); + ctx.line_to(cx, tip); + ctx.move_to(cx - 4.0, back); + ctx.line_to(cx, tip); + ctx.line_to(cx + 4.0, back); + if to_end { + let bar = if up { cy - 6.5 } else { cy + 6.5 }; + ctx.move_to(cx - 6.0, bar); + ctx.line_to(cx + 6.0, bar); + } + let _ = ctx.stroke(); +} From 6a15bd534c2083e61fa6d2ac5b18f10a4ab886b2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:16:37 +0200 Subject: [PATCH 04/12] feat: opacity for every shape and sliders in the properties panel The panel gains an Opacity row for every colored shape, stepped in 5% increments from 5% to 100%; a marker stays at or below 90%, as the marker tool keeps it. Opacity has its own control now, so an opaque swatch or color key changes the hue and keeps each shape's opacity, while a translucent swatch still sets its own. The color picker's OK sets exactly the color shown, opacity included. Thickness and Opacity are sliders: pressing the track jumps to the pointer, the shapes follow the drag live, and the whole drag lands as one undo entry on release, or when the panel closes mid-drag. The keyboard and the wheel still step them. The thickness stroke preview gives way to the slider's filled track. Opacity stays off the toolbar's style pill. --- README.md | 2 +- .../state/core/color_picker_popup/state.rs | 5 +- src/input/state/core/mod.rs | 2 +- src/input/state/core/properties/apply.rs | 10 +- .../apply_selection/actions/color.rs | 130 +++++++-- .../properties/apply_selection/actions/mod.rs | 37 +++ .../apply_selection/actions/stroke.rs | 22 ++ .../properties/apply_selection/constants.rs | 5 + .../core/properties/apply_selection/mod.rs | 3 + src/input/state/core/properties/entries.rs | 21 +- src/input/state/core/properties/metrics.rs | 11 +- src/input/state/core/properties/mod.rs | 4 +- src/input/state/core/properties/panel.rs | 6 + .../core/properties/panel_layout/geometry.rs | 61 +++-- .../properties/panel_layout/interaction.rs | 31 ++- .../core/properties/panel_layout/layout.rs | 41 ++- .../core/properties/panel_layout/tests.rs | 5 +- src/input/state/core/properties/slider.rs | 124 +++++++++ src/input/state/core/properties/state.rs | 1 + src/input/state/core/properties/summary.rs | 9 + src/input/state/core/properties/types.rs | 80 +++++- .../state/interaction/adapters/pointer.rs | 6 +- src/input/state/interaction/pointer.rs | 6 +- src/input/state/mod.rs | 2 +- src/input/state/mouse/press/panels.rs | 18 +- .../state/tests/properties_panel_controls.rs | 251 +++++++++++++++--- src/ui/properties_panel.rs | 36 ++- src/ui/properties_panel/controls.rs | 103 +++++-- src/ui/toolbar/model/style_pill.rs | 5 + .../model/style_pill/tests/selection.rs | 19 ++ 30 files changed, 891 insertions(+), 165 deletions(-) create mode 100644 src/input/state/core/properties/slider.rs diff --git a/README.md b/README.md index 41a65180..00b4132c 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) -- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time +- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index 16b304b6..b48dd62e 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -12,6 +12,7 @@ use super::{ PickerDrag, color_to_hex, hsv_to_rgb, parse_hex_color, rgb_to_hsv, }; use crate::input::state::core::modal::ModalSurface; +use crate::input::state::core::properties::RecolorOpacity; /// Paper has no alpha, so every color the paper target takes is opaque. fn opaque(color: Color) -> Color { @@ -343,10 +344,10 @@ impl InputState { // was only the first shape's, so a selection whose shapes disagree in // hue or opacity still takes an accepted color that matches it. if let Some((_, ColorPickerTarget::Selection, _, color)) = applied_color - && self.selection_recolor_changes(color) + && self.selection_recolor_changes(color, RecolorOpacity::Exact) { self.finish_active_arrow_bend(); - if self.apply_selection_color_value_with(measurer, color) { + if self.recolor_selection_with(measurer, color, RecolorOpacity::Exact) { self.note_recent_color(color); } } diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 2e142051..3f60ceed 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -126,7 +126,7 @@ pub use menus::{ pub use menus::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY}; pub(crate) use properties::metrics as properties_panel_metrics; pub use properties::{ - PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, }; diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index ed48512b..98e18666 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -1,4 +1,5 @@ use super::super::base::InputState; +use super::RecolorOpacity; use super::types::{ PanelAction, PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, @@ -25,9 +26,11 @@ impl InputState { }; let changed = match hit { + // A slider acts on the press and the drag, not on the click. PropertiesPanelHit::Title | PropertiesPanelHit::Lock - | PropertiesPanelHit::Action(_) => false, + | PropertiesPanelHit::Action(_) + | PropertiesPanelHit::Slider(_) => false, PropertiesPanelHit::Swatch { index, .. } => { let Some(color) = self .properties @@ -118,7 +121,7 @@ impl InputState { // Picking the color the selection already has is a no-op, not a // "No changes applied" toast. A swatch with the same hue still // changes a shape of another opacity. - if !self.selection_recolor_changes(color) { + if !self.selection_recolor_changes(color, RecolorOpacity::Swatch) { return false; } self.finish_active_arrow_bend(); @@ -321,6 +324,9 @@ impl InputState { SelectionPropertyKind::Thickness => { self.apply_selection_thickness(measurer, direction_or_default(direction)) } + SelectionPropertyKind::Opacity => { + self.apply_selection_opacity(measurer, direction_or_default(direction)) + } SelectionPropertyKind::Fill => self.apply_selection_fill(measurer, direction), SelectionPropertyKind::FontSize => { self.apply_selection_font_size(measurer, direction_or_default(direction)) diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 93d525f4..3cbd5e6c 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -1,15 +1,43 @@ use crate::draw::TextMeasurer; use crate::draw::{Color, Shape}; use crate::input::state::core::base::InputState; +use crate::input::state::core::properties::apply_selection::constants::{ + MAX_MARKER_OPACITY, MIN_OPACITY, OPACITY_STEP, +}; use crate::input::state::core::properties::utils::{cycle_index, palette_position, palette_step}; use crate::input::state::{Toast, ToastPriority}; +/// How a recolor treats each shape's opacity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecolorOpacity { + /// A swatch or color key picks the hue. Opacity has its own control, so + /// each shape keeps its own, unless the swatch itself is translucent: + /// then its opacity is part of the choice. + Swatch, + /// The color picker's OK sets exactly the color shown, opacity included. + Exact, +} + /// The color `shape` has now and the one a recolor to `target` gives it, or -/// `None` for a shape without a color. A marker keeps its own opacity, which -/// is what makes it a highlighter; every other shape takes `target` whole, -/// opacity included. -fn recolored(shape: &Shape, target: Color) -> Option<(Color, Color)> { - let current = match shape { +/// `None` for a shape without a color. A marker keeps its own opacity either +/// way, which is what makes it a highlighter. +fn recolored(shape: &Shape, target: Color, opacity: RecolorOpacity) -> Option<(Color, Color)> { + let current = *shape_color_ref(shape)?; + let keep_opacity = matches!(shape, Shape::MarkerStroke { .. }) + || (opacity == RecolorOpacity::Swatch && target.a >= 1.0); + let next = if keep_opacity { + Color { + a: current.a, + ..target + } + } else { + target + }; + Some((current, next)) +} + +fn shape_color_ref(shape: &Shape) -> Option<&Color> { + match shape { Shape::Freehand { color, .. } | Shape::FreehandPressure { color, .. } | Shape::Line { color, .. } @@ -19,18 +47,10 @@ fn recolored(shape: &Shape, target: Color) -> Option<(Color, Color)> { | Shape::Arrow { color, .. } | Shape::MarkerStroke { color, .. } | Shape::Text { color, .. } - | Shape::StepMarker { color, .. } => *color, - Shape::StickyNote { background, .. } => *background, - _ => return None, - }; - let next = match shape { - Shape::MarkerStroke { .. } => Color { - a: current.a, - ..target - }, - _ => target, - }; - Some((current, next)) + | Shape::StepMarker { color, .. } => Some(color), + Shape::StickyNote { background, .. } => Some(background), + _ => None, + } } fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> { @@ -50,17 +70,57 @@ fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> { } } +/// The opacity range a shape may take: a marker stays translucent, as the +/// marker tool keeps it. +fn opacity_range(shape: &Shape) -> (f64, f64) { + match shape { + Shape::MarkerStroke { .. } => (MIN_OPACITY, MAX_MARKER_OPACITY), + _ => (MIN_OPACITY, 1.0), + } +} + +pub(super) fn has_color(shape: &Shape) -> bool { + shape_color_ref(shape).is_some() +} + +/// Sets a shape's opacity to `value`, clamped to its range. +pub(super) fn set_opacity_to(shape: &mut Shape, value: f64) -> Option { + set_opacity(shape, |_| value) +} + +/// Sets a shape's opacity, clamped to its range: whether it changed, or +/// `None` for a shape without a color. +fn set_opacity(shape: &mut Shape, opacity: impl FnOnce(f64) -> f64) -> Option { + let (min, max) = opacity_range(shape); + let color = shape_color_mut(shape)?; + let next = opacity(color.a).clamp(min, max); + let changed = (next - color.a).abs() > f64::EPSILON; + color.a = next; + Some(changed) +} + impl InputState { + /// Recolors the selection from a swatch, a color key, or the eyedropper: + /// see [`RecolorOpacity::Swatch`]. pub(crate) fn apply_selection_color_value_with( &mut self, measurer: &TextMeasurer, target: Color, + ) -> bool { + self.recolor_selection_with(measurer, target, RecolorOpacity::Swatch) + } + + pub(crate) fn recolor_selection_with( + &mut self, + measurer: &TextMeasurer, + target: Color, + opacity: RecolorOpacity, ) -> bool { let result = self.apply_selection_change_with( measurer, - |shape| recolored(shape, target).is_some(), + |shape| recolored(shape, target, opacity).is_some(), |shape| { - let Some((current, next)) = recolored(shape, target) else { + let Some((current, next)) = recolored(shape, target, opacity) else { return false; }; if current == next { @@ -78,18 +138,40 @@ impl InputState { } /// Whether recoloring the selection to `target` would change any shape it - /// may edit, opacity included. Surfaces that set a color directly ask - /// this first, so picking the color a selection already has is quiet. - pub(crate) fn selection_recolor_changes(&self, target: Color) -> bool { + /// may edit. Surfaces that set a color directly ask this first, so picking + /// the color a selection already has is quiet. + pub(crate) fn selection_recolor_changes(&self, target: Color, opacity: RecolorOpacity) -> bool { let frame = self.boards.active_frame(); self.selected_shape_ids() .iter() .filter_map(|id| frame.shape(*id)) .filter(|drawn| !drawn.locked) - .filter_map(|drawn| recolored(&drawn.shape, target)) + .filter_map(|drawn| recolored(&drawn.shape, target, opacity)) .any(|(current, next)| current != next) } + /// Steps every editable selected shape's opacity by `direction` steps of + /// 5%, on the 5% grid, within its range. + pub(in crate::input::state::core::properties) fn apply_selection_opacity( + &mut self, + measurer: &TextMeasurer, + direction: i32, + ) -> bool { + let steps = f64::from(direction); + let result = self.apply_selection_change_with( + measurer, + |shape| shape_color_ref(shape).is_some(), + |shape| { + set_opacity(shape, |alpha| { + ((alpha / OPACITY_STEP).round() + steps) * OPACITY_STEP + }) + .unwrap_or(false) + }, + ); + + self.report_selection_apply_result(result, "opacity") + } + /// Steps the selection's color through the quick-color palette, the same /// swatches the toolbar and the properties panel offer. pub(in crate::input::state::core::properties) fn apply_selection_color( @@ -121,7 +203,7 @@ impl InputState { // nothing, and stopping there would pin every later step to it. let mut next = first; for _ in 0..palette.len() { - if self.selection_recolor_changes(palette[next]) { + if self.selection_recolor_changes(palette[next], RecolorOpacity::Swatch) { break; } next = cycle_index(next, palette.len(), offset); diff --git a/src/input/state/core/properties/apply_selection/actions/mod.rs b/src/input/state/core/properties/apply_selection/actions/mod.rs index 1e80d1cf..008d9487 100644 --- a/src/input/state/core/properties/apply_selection/actions/mod.rs +++ b/src/input/state/core/properties/apply_selection/actions/mod.rs @@ -4,3 +4,40 @@ mod fill; mod spotlight; mod stroke; mod text; + +pub(crate) use color::RecolorOpacity; + +use crate::draw::Shape; +use crate::input::state::core::base::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +use crate::input::state::core::properties::summary::shape_thickness; +use crate::input::state::core::properties::types::SelectionPropertyKind; + +/// Whether a slider for `kind` has anything to set on `shape`. +pub(in crate::input::state::core::properties) fn level_applies( + kind: SelectionPropertyKind, + shape: &Shape, +) -> bool { + match kind { + SelectionPropertyKind::Thickness => shape_thickness(shape).is_some(), + SelectionPropertyKind::Opacity => color::has_color(shape), + _ => false, + } +} + +/// Sets `kind` to `value` on one shape, as a slider drag previews it: +/// whether the shape changed. +pub(in crate::input::state::core::properties) fn set_level( + kind: SelectionPropertyKind, + shape: &mut Shape, + value: f64, +) -> bool { + match kind { + SelectionPropertyKind::Thickness => stroke::set_thickness( + shape, + value.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS), + ) + .unwrap_or(false), + SelectionPropertyKind::Opacity => color::set_opacity_to(shape, value).unwrap_or(false), + _ => false, + } +} diff --git a/src/input/state/core/properties/apply_selection/actions/stroke.rs b/src/input/state/core/properties/apply_selection/actions/stroke.rs index 06d3f67d..2b918bbd 100644 --- a/src/input/state/core/properties/apply_selection/actions/stroke.rs +++ b/src/input/state/core/properties/apply_selection/actions/stroke.rs @@ -90,3 +90,25 @@ impl InputState { self.report_selection_apply_result(result, "thickness") } } + +/// Sets a stroke's width to `target`: whether it changed, or `None` for a +/// shape without one. +pub(super) fn set_thickness(shape: &mut Shape, target: f64) -> Option { + match shape { + Shape::Freehand { thick, .. } + | Shape::Line { thick, .. } + | Shape::Rect { thick, .. } + | Shape::Ellipse { thick, .. } + | Shape::Polygon { thick, .. } + | Shape::Arrow { thick, .. } + | Shape::BlurRect { + strength: thick, .. + } + | Shape::MarkerStroke { thick, .. } => { + let changed = (*thick - target).abs() > f64::EPSILON; + *thick = target; + Some(changed) + } + _ => None, + } +} diff --git a/src/input/state/core/properties/apply_selection/constants.rs b/src/input/state/core/properties/apply_selection/constants.rs index 64046df6..fdf198db 100644 --- a/src/input/state/core/properties/apply_selection/constants.rs +++ b/src/input/state/core/properties/apply_selection/constants.rs @@ -8,3 +8,8 @@ pub(super) const MIN_ARROW_LENGTH: f64 = 5.0; pub(super) const MAX_ARROW_LENGTH: f64 = 50.0; pub(super) const MIN_ARROW_ANGLE: f64 = 15.0; pub(super) const MAX_ARROW_ANGLE: f64 = 60.0; +/// Opacity steps in 5% increments from 5%; a marker stays at or below 90%, +/// the marker tool's own ceiling. +pub(in crate::input::state::core::properties) const OPACITY_STEP: f64 = 0.05; +pub(in crate::input::state::core::properties) const MIN_OPACITY: f64 = 0.05; +pub(in crate::input::state::core::properties) const MAX_MARKER_OPACITY: f64 = 0.9; diff --git a/src/input/state/core/properties/apply_selection/mod.rs b/src/input/state/core/properties/apply_selection/mod.rs index aff2b48f..1c5380a6 100644 --- a/src/input/state/core/properties/apply_selection/mod.rs +++ b/src/input/state/core/properties/apply_selection/mod.rs @@ -1,3 +1,6 @@ mod actions; + +pub(crate) use actions::RecolorOpacity; +pub(super) use actions::{level_applies, set_level}; mod constants; mod helpers; diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index 6422625f..d7da7a7a 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -1,8 +1,8 @@ use super::super::base::InputState; use super::summary::{ PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_arrow_style, - shape_color, shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background, - shape_thickness, summarize_property, + shape_color, shape_fill, shape_font_size, shape_opacity, shape_spotlight_magnification, + shape_text_background, shape_thickness, summarize_property, }; use super::types::{SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue}; use super::utils::{approx_eq, color_label, color_rgba_eq}; @@ -91,7 +91,7 @@ impl InputState { SelectionPropertyKind::Thickness, &thickness_summary, |v| format!("{v:.1}px"), - SelectionPropertyValue::Number, + SelectionPropertyValue::Level, )); } else { let mut any_pressure = false; @@ -135,6 +135,17 @@ impl InputState { } } + let opacity_summary = summarize_property(frame, ids, shape_opacity, approx_eq); + if opacity_summary.applicable { + entries.push(entry( + "Opacity", + SelectionPropertyKind::Opacity, + &opacity_summary, + |v| format!("{:.0}%", v * 100.0), + SelectionPropertyValue::Level, + )); + } + let fill_summary = summarize_property(frame, ids, shape_fill, |a, b| a == b); if fill_summary.applicable { entries.push(entry( @@ -442,7 +453,7 @@ mod tests { assert_eq!( entry(&entries, "Thickness").state, - SelectionPropertyValue::Number(Some(3.0)) + SelectionPropertyValue::Level(Some(3.0)) ); assert_eq!( entry(&entries, "Arrow head").state, @@ -481,7 +492,7 @@ mod tests { let entries = state.build_selection_property_entries(&[first, second]); assert_eq!( entry(&entries, "Thickness").state, - SelectionPropertyValue::Number(None) + SelectionPropertyValue::Level(None) ); assert!(entry(&entries, "Thickness").disabled); } diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs index 443681ad..0b7556b5 100644 --- a/src/input/state/core/properties/metrics.rs +++ b/src/input/state/core/properties/metrics.rs @@ -51,9 +51,14 @@ pub(crate) const STEP_BUTTON_WIDTH: f64 = 24.0; pub(crate) const STEPPER_HEIGHT: f64 = 24.0; pub(crate) const STEPPER_MIN_VALUE_WIDTH: f64 = 52.0; pub(crate) const STEPPER_VALUE_PADDING: f64 = 16.0; -pub(crate) const PREVIEW_WIDTH: f64 = 26.0; -pub(crate) const PREVIEW_HEIGHT: f64 = 12.0; -pub(crate) const PREVIEW_GAP: f64 = 8.0; + +/// A slider row: its track's hit band, the drawn track inside it, the thumb, +/// the narrowest track a panel allows, and the gap before the readout. +pub(crate) const SLIDER_HIT_HEIGHT: f64 = 24.0; +pub(crate) const SLIDER_TRACK_HEIGHT: f64 = 4.0; +pub(crate) const SLIDER_THUMB_RADIUS: f64 = 7.0; +pub(crate) const SLIDER_MIN_TRACK: f64 = 96.0; +pub(crate) const SLIDER_VALUE_GAP: f64 = 10.0; pub(crate) const SWITCH_WIDTH: f64 = 34.0; pub(crate) const SWITCH_HEIGHT: f64 = 20.0; diff --git a/src/input/state/core/properties/mod.rs b/src/input/state/core/properties/mod.rs index b9aa0daf..c90f3990 100644 --- a/src/input/state/core/properties/mod.rs +++ b/src/input/state/core/properties/mod.rs @@ -4,14 +4,16 @@ mod entries; pub(crate) mod metrics; mod panel; mod panel_layout; +mod slider; mod state; mod summary; mod types; mod utils; +pub(crate) use apply_selection::RecolorOpacity; pub use state::PropertiesPanelState; pub use types::{ - PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel, }; diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 588fb9db..b6e3d475 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -55,6 +55,12 @@ impl InputState { } pub fn close_properties_panel(&mut self) { + // A drag cut short by the panel closing keeps what it previewed, as + // one undo entry, rather than leaving the shapes changed off the + // record. + if self.is_properties_slider_dragging() { + self.finish_properties_slider_drag_with(&TextMeasurer::default()); + } if self.properties.close() { self.dirty_tracker.mark_full(); self.needs_redraw = true; diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index 5102802f..4619702d 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -4,15 +4,16 @@ use super::super::metrics::{ ACTION_BUTTON_GAP, ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_LABEL_WIDTH, ACTIONS_TOP_GAP, - BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING, PREVIEW_GAP, - PREVIEW_HEIGHT, PREVIEW_WIDTH, ROW_HEIGHT, ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, - STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP, STYLE_BUTTON_HEIGHT, SWATCH_GAP, - SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, TITLE_FONT, + BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING, ROW_HEIGHT, + ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, SLIDER_HIT_HEIGHT, SLIDER_THUMB_RADIUS, + SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP, STYLE_BUTTON_HEIGHT, + SWATCH_GAP, SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, + TITLE_FONT, }; use super::super::types::{ PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind, - SelectionPropertyValue, ShapePropertiesPanel, + PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyValue, + ShapePropertiesPanel, }; use super::super::utils::palette_position; use crate::draw::ArrowStyle; @@ -29,7 +30,8 @@ enum RowShape { fn row_shape(entry: &SelectionPropertyEntry) -> RowShape { match entry.state { SelectionPropertyValue::Color(_) | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block, - SelectionPropertyValue::Number(_) + SelectionPropertyValue::Level(_) + | SelectionPropertyValue::Number(_) | SelectionPropertyValue::PressureVaries | SelectionPropertyValue::Toggle(_) | SelectionPropertyValue::ArrowHead(_) => RowShape::Inline, @@ -109,14 +111,6 @@ pub(in crate::input::state::core::properties) fn balanced_column_budget( high } -/// Whether a stepper row draws a stroke at its current thickness. -pub(in crate::input::state::core::properties) fn shows_thickness_preview( - entry: &SelectionPropertyEntry, -) -> bool { - entry.kind == SelectionPropertyKind::Thickness - && matches!(entry.state, SelectionPropertyValue::Number(Some(_))) -} - impl PropertiesPanelLayout { pub fn rect(&self) -> PanelRect { PanelRect::new(self.origin_x, self.origin_y, self.width, self.height) @@ -283,6 +277,24 @@ impl PropertiesPanelLayout { ); PropertiesRowControl::ArrowHead { well, start, end } } + SelectionPropertyValue::Level(_) => { + let value = PanelRect::new( + right - self.slider_value_width, + center_y - SLIDER_HIT_HEIGHT / 2.0, + self.slider_value_width, + SLIDER_HIT_HEIGHT, + ); + let track_x = left + self.label_column; + PropertiesRowControl::Slider { + track: PanelRect::new( + track_x, + center_y - SLIDER_HIT_HEIGHT / 2.0, + (value.x - SLIDER_VALUE_GAP - track_x).max(SLIDER_THUMB_RADIUS * 2.0), + SLIDER_HIT_HEIGHT, + ), + value, + } + } _ => { let total = STEP_BUTTON_WIDTH * 2.0 + self.stepper_value_width; let x = right - total; @@ -291,20 +303,7 @@ impl PropertiesPanelLayout { let value = PanelRect::new(down.right(), y, self.stepper_value_width, STEPPER_HEIGHT); let up = PanelRect::new(value.right(), y, STEP_BUTTON_WIDTH, STEPPER_HEIGHT); - let preview = shows_thickness_preview(entry).then(|| { - PanelRect::new( - x - PREVIEW_GAP - PREVIEW_WIDTH, - center_y - PREVIEW_HEIGHT / 2.0, - PREVIEW_WIDTH, - PREVIEW_HEIGHT, - ) - }); - PropertiesRowControl::Stepper { - down, - value, - up, - preview, - } + PropertiesRowControl::Stepper { down, value, up } } }; @@ -379,6 +378,9 @@ impl PropertiesPanelLayout { }) } } + PropertiesRowControl::Slider { track, .. } => track + .contains(x, y) + .then_some(PropertiesPanelHit::Slider(index)), PropertiesRowControl::Stepper { down, up, .. } => { if down.contains(x, y) { Some(PropertiesPanelHit::StepDown(index)) @@ -446,6 +448,7 @@ impl PropertiesPanelLayout { (PropertiesPanelHit::MoreColors(_), PropertiesRowControl::Swatches { more, .. }) => { more } + (PropertiesPanelHit::Slider(_), PropertiesRowControl::Slider { track, .. }) => track, (PropertiesPanelHit::StepDown(_), PropertiesRowControl::Stepper { down, .. }) => down, (PropertiesPanelHit::StepUp(_), PropertiesRowControl::Stepper { up, .. }) => up, (PropertiesPanelHit::Toggle(_), PropertiesRowControl::Toggle { switch }) => switch, diff --git a/src/input/state/core/properties/panel_layout/interaction.rs b/src/input/state/core/properties/panel_layout/interaction.rs index 02f04639..c6264316 100644 --- a/src/input/state/core/properties/panel_layout/interaction.rs +++ b/src/input/state/core/properties/panel_layout/interaction.rs @@ -76,14 +76,39 @@ impl InputState { self.update_properties_panel_hover_from_pointer_internal(x, y, true); } + /// Pointer motion over an open panel: a slider drag follows it, and + /// otherwise the hover does. + pub(crate) fn move_properties_panel_pointer_with( + &mut self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) { + if self.is_properties_slider_dragging() { + self.drag_properties_slider_with(measurer, x); + } else { + self.update_properties_panel_hover_from_pointer(x, y); + } + } + /// A primary press at `(x, y)`. Returns false when it landed off the /// panel. The pressed part is remembered for the release, and its row /// takes (quiet) keyboard focus so arrow keys continue from the click. - pub(crate) fn press_properties_panel_at(&mut self, x: i32, y: i32) -> bool { + pub(crate) fn press_properties_panel_at_with( + &mut self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) -> bool { if !self.properties_panel_contains(x, y) { return false; } let hit = self.properties_panel_active_hit_at(x, y); + // A slider acts on the press itself: the value jumps to the pointer + // and follows it until the release. + if let Some(PropertiesPanelHit::Slider(row)) = hit { + let _ = self.begin_properties_slider_drag_with(measurer, row, x); + } if let Some(panel) = self.properties.panel.as_mut() { panel.pressed = hit; if let Some(row) = hit.and_then(PropertiesPanelHit::row) { @@ -108,6 +133,10 @@ impl InputState { .panel .as_mut() .and_then(|panel| panel.pressed.take()); + if self.is_properties_slider_dragging() { + self.finish_properties_slider_drag_with(measurer); + return; + } match pressed { Some(hit) => { if self.properties_panel_active_hit_at(x, y) == Some(hit) { diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index 8abc8622..68159920 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -4,9 +4,9 @@ use cairo::FontWeight; use super::super::super::base::InputState; use super::super::metrics::{ ACTIONS_HEIGHT, BODY_FONT, COLUMN_GAP, COLUMN_SPACING, EMPTY_HEIGHT, FOOTER_HEIGHT, HEADER_GAP, - LOCK_INSET, LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, PREVIEW_GAP, - PREVIEW_WIDTH, ROW_HEIGHT, ROWS_GAP, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, - SEGMENT_TEXT_PADDING, STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING, + LOCK_INSET, LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, ROW_HEIGHT, ROWS_GAP, + SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, SEGMENT_TEXT_PADDING, SLIDER_MIN_TRACK, + SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING, STYLE_BUTTON_GAP, STYLE_BUTTON_MIN_WIDTH, SUBTITLE_FONT, SUBTITLE_STEP, SWITCH_VALUE_GAP, SWITCH_WIDTH, TITLE_FONT, TOOLTIP_FONT, TOOLTIP_GAP, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y, VALUE_FONT, text_style, @@ -15,9 +15,7 @@ use super::super::types::{ PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, SelectionPropertyValue, ShapePropertiesPanel, }; -use super::geometry::{ - balanced_column_budget, column_slots, row_height, shows_thickness_preview, swatch_grid_width, -}; +use super::geometry::{balanced_column_budget, column_slots, row_height, swatch_grid_width}; use super::{PANEL_ANCHOR_GAP, PANEL_MARGIN}; use crate::draw::ArrowStyle; use crate::ui_text::UiTextEngine; @@ -29,8 +27,14 @@ struct Measured { content_width: f64, stepper_value_width: f64, head_segment_width: f64, + label_column: f64, + slider_value_width: f64, } +/// Values a slider readout must have room for, whatever it shows now, so the +/// track keeps its length while a drag changes the readout. +const SLIDER_READOUT_SAMPLES: [&str; 4] = ["50.0px", "100%", "Mixed", "Locked"]; + fn measure_panel( engine: &UiTextEngine, ctx: &CairoContext, @@ -51,6 +55,10 @@ fn measure_panel( let mut label_width: f64 = 0.0; let mut stepper_text_width: f64 = 0.0; let mut toggle_value_width: f64 = 0.0; + let mut slider_text_width: f64 = SLIDER_READOUT_SAMPLES + .into_iter() + .map(|text| width(VALUE_FONT, FontWeight::Normal, text)) + .fold(0.0, f64::max); for entry in &panel.entries { label_width = label_width.max(width(BODY_FONT, FontWeight::Normal, &entry.label)); match entry.state { @@ -65,9 +73,15 @@ fn measure_panel( toggle_value_width = toggle_value_width.max(width(VALUE_FONT, FontWeight::Normal, &entry.value)); } + SelectionPropertyValue::Level(_) => { + slider_text_width = + slider_text_width.max(width(VALUE_FONT, FontWeight::Normal, &entry.value)); + } _ => {} } } + let label_column = (label_width + COLUMN_GAP).ceil(); + let slider_value_width = slider_text_width.ceil(); let head_text_width = ["Start", "End"] .into_iter() .map(|text| width(VALUE_FONT, FontWeight::Normal, text)) @@ -101,15 +115,10 @@ fn measure_panel( let count = ArrowStyle::ALL.len() as f64; label_width.max(STYLE_BUTTON_MIN_WIDTH * count + STYLE_BUTTON_GAP * (count - 1.0)) } - SelectionPropertyValue::Number(_) => { - let preview = if shows_thickness_preview(entry) { - PREVIEW_WIDTH + PREVIEW_GAP - } else { - 0.0 - }; - label_width + COLUMN_GAP + preview + STEP_BUTTON_WIDTH * 2.0 + stepper_value_width + SelectionPropertyValue::Level(_) => { + label_column + SLIDER_MIN_TRACK + SLIDER_VALUE_GAP + slider_value_width } - SelectionPropertyValue::PressureVaries => { + SelectionPropertyValue::Number(_) | SelectionPropertyValue::PressureVaries => { label_width + COLUMN_GAP + STEP_BUTTON_WIDTH * 2.0 + stepper_value_width } SelectionPropertyValue::Toggle(_) => { @@ -127,6 +136,8 @@ fn measure_panel( content_width, stepper_value_width, head_segment_width, + label_column, + slider_value_width, } } @@ -255,6 +266,8 @@ impl InputState { footer_top: has_footer.then_some(origin_y + rows_top + fit.height + ACTIONS_HEIGHT), stepper_value_width: measured.stepper_value_width, head_segment_width: measured.head_segment_width, + label_column: measured.label_column, + slider_value_width: measured.slider_value_width, column_width, column_budget: fit.budget, scroll: scroll.map(|(offset, max_offset)| PanelScroll { diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs index 4f5abe62..5c45aa03 100644 --- a/src/input/state/core/properties/panel_layout/tests.rs +++ b/src/input/state/core/properties/panel_layout/tests.rs @@ -105,6 +105,9 @@ fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelH PropertiesRowControl::Toggle { switch } => { vec![(*switch, PropertiesPanelHit::Toggle(index))] } + PropertiesRowControl::Slider { track, .. } => { + vec![(*track, PropertiesPanelHit::Slider(index))] + } PropertiesRowControl::ArrowHead { start, end, .. } => vec![ ( *start, @@ -521,7 +524,7 @@ fn scrolling_after_a_click_moves_hover_off_the_control_that_scrolled_away() { // A click remembers the row quietly, and the pointer rests on the swatch. state.update_pointer_position(x, y); - assert!(state.press_properties_panel_at(x, y)); + assert!(state.press_properties_panel_at_with(&measurer, x, y)); state.release_properties_panel_at_with(&measurer, x, y); lay_out(&mut state, screen); state.update_properties_panel_hover_from_pointer(x, y); diff --git a/src/input/state/core/properties/slider.rs b/src/input/state/core/properties/slider.rs new file mode 100644 index 00000000..6fbbe1f9 --- /dev/null +++ b/src/input/state/core/properties/slider.rs @@ -0,0 +1,124 @@ +//! Dragging a properties-panel slider: the shapes follow the thumb live, and +//! the whole drag lands as one undo entry when it ends. + +use super::super::base::InputState; +use super::apply_selection::{level_applies, set_level}; +use super::types::{PropertiesRowControl, SelectionPropertyKind}; +use crate::draw::frame::ShapeSnapshot; +use crate::draw::{ShapeId, TextMeasurer}; +use crate::input::state::core::editing::CanvasEdit; + +/// A slider drag in progress: the shapes as they were when it began, so every +/// step previews from them and the release commits against them. +#[derive(Debug)] +pub(crate) struct SliderDrag { + row: usize, + kind: SelectionPropertyKind, + snapshots: Vec<(ShapeId, ShapeSnapshot)>, + value: Option, +} + +impl InputState { + pub(crate) fn is_properties_slider_dragging(&self) -> bool { + self.properties.slider_drag.is_some() + } + + /// Starts dragging the slider on `row`, with the pointer at `x`, which + /// moves the value there at once. Returns false when the row has no + /// editable slider. + pub(crate) fn begin_properties_slider_drag_with( + &mut self, + measurer: &TextMeasurer, + row: usize, + x: i32, + ) -> bool { + let Some(entry) = self + .properties + .panel + .as_ref() + .and_then(|panel| panel.entries.get(row)) + .filter(|entry| !entry.disabled && entry.kind.level_range().is_some()) + else { + return false; + }; + let kind = entry.kind; + + self.finish_active_arrow_bend(); + let frame = self.boards.active_frame(); + let editable: Vec = self + .selected_shape_ids() + .iter() + .copied() + .filter(|id| { + frame + .shape(*id) + .is_some_and(|drawn| !drawn.locked && level_applies(kind, &drawn.shape)) + }) + .collect(); + let snapshots = CanvasEdit::capture(frame, &editable).into_snapshots(); + self.properties.slider_drag = Some(SliderDrag { + row, + kind, + snapshots, + value: None, + }); + + self.drag_properties_slider_with(measurer, x); + true + } + + /// Moves the dragged slider's value to the pointer at `x`, previewing it + /// on the shapes without recording history. + pub(crate) fn drag_properties_slider_with(&mut self, measurer: &TextMeasurer, x: i32) { + let Some(mut drag) = self.properties.slider_drag.take() else { + return; + }; + let track = self.properties_slider_track(drag.row); + let value = track + .zip(drag.kind.level_range()) + .map(|(track, range)| range.value_at_x(track, f64::from(x))); + + if let Some(value) = value + && drag.value != Some(value) + { + drag.value = Some(value); + let kind = drag.kind; + let effects = CanvasEdit::borrow_snapshots(&drag.snapshots).preview( + self.boards.active_frame_mut(), + measurer, + |shape, _| set_level(kind, shape, value), + ); + self.apply_edit_effects(measurer, effects); + self.properties.slider_drag = Some(drag); + self.refresh_properties_panel_with(measurer); + } else { + self.properties.slider_drag = Some(drag); + } + } + + /// Ends a slider drag, recording everything it changed as one undo entry. + pub(crate) fn finish_properties_slider_drag_with(&mut self, measurer: &TextMeasurer) { + let Some(drag) = self.properties.slider_drag.take() else { + return; + }; + + let effects = CanvasEdit::from_snapshots(drag.snapshots).commit( + self.boards.active_frame_mut(), + self.history_limits.undo_stack_limit(), + ); + self.apply_edit_effects(measurer, effects); + + if self.is_properties_panel_open() { + self.refresh_properties_panel_with(measurer); + } + } + + fn properties_slider_track(&self, row: usize) -> Option { + let panel = self.properties.panel.as_ref()?; + let layout = self.properties.layout.as_ref()?; + match layout.rows(panel).into_iter().nth(row)?.control { + PropertiesRowControl::Slider { track, .. } => Some(track), + _ => None, + } + } +} diff --git a/src/input/state/core/properties/state.rs b/src/input/state/core/properties/state.rs index c840658a..7f459bc9 100644 --- a/src/input/state/core/properties/state.rs +++ b/src/input/state/core/properties/state.rs @@ -7,6 +7,7 @@ pub struct PropertiesPanelState { pub(in crate::input::state) layout: Option, pub(super) pending_hover_recalc: bool, pub(super) needs_refresh: bool, + pub(super) slider_drag: Option, } impl PropertiesPanelState { diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs index dece88ff..88e45b61 100644 --- a/src/input/state/core/properties/summary.rs +++ b/src/input/state/core/properties/summary.rs @@ -81,6 +81,15 @@ pub(super) fn shape_color(shape: &Shape) -> Option { } } +/// The opacity a shape is drawn at: its color's alpha, a marker's own +/// translucency included. +pub(super) fn shape_opacity(shape: &Shape) -> Option { + match shape { + Shape::MarkerStroke { color, .. } => Some(color.a), + _ => shape_color(shape).map(|color| color.a), + } +} + pub(super) fn shape_thickness(shape: &Shape) -> Option { match shape { Shape::Freehand { thick, .. } diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 2c64e75d..26977d57 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -1,3 +1,4 @@ +use super::metrics::SLIDER_THUMB_RADIUS; use crate::draw::{ArrowStyle, Color}; use crate::util::Rect; @@ -5,6 +6,7 @@ use crate::util::Rect; pub enum SelectionPropertyKind { Color, Thickness, + Opacity, Fill, FontSize, ArrowHead, @@ -15,7 +17,61 @@ pub enum SelectionPropertyKind { SpotlightMagnification, } +/// A slider's range and the grid its values snap to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LevelRange { + pub min: f64, + pub max: f64, + pub step: f64, +} + +impl LevelRange { + /// Where `value` sits along the range, from 0.0 to 1.0. + pub fn fraction(&self, value: f64) -> f64 { + ((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0) + } + + /// The value at `fraction` along the range, on the step grid. + pub fn value_at(&self, fraction: f64) -> f64 { + let raw = self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min); + (self.min + ((raw - self.min) / self.step).round() * self.step).clamp(self.min, self.max) + } + + /// The value with the pointer at `x` on a slider `track`: the thumb's + /// center follows the pointer, and the ends of travel sit a thumb's + /// radius in. + pub fn value_at_x(&self, track: PanelRect, x: f64) -> f64 { + self.value_at((x - track.x - SLIDER_THUMB_RADIUS) / slider_travel(track)) + } + + /// Where the thumb centers on `track` for `value`. + pub fn thumb_x(&self, track: PanelRect, value: f64) -> f64 { + track.x + SLIDER_THUMB_RADIUS + self.fraction(value) * slider_travel(track) + } +} + +fn slider_travel(track: PanelRect) -> f64 { + (track.width - SLIDER_THUMB_RADIUS * 2.0).max(1.0) +} + impl SelectionPropertyKind { + /// The slider range of a property set on a slider. + pub fn level_range(self) -> Option { + match self { + Self::Thickness => Some(LevelRange { + min: crate::domain::MIN_STROKE_THICKNESS, + max: crate::domain::MAX_STROKE_THICKNESS, + step: 1.0, + }), + Self::Opacity => Some(LevelRange { + min: 0.05, + max: 1.0, + step: 0.05, + }), + _ => None, + } + } + /// The kind's value with nothing known about it, for fixtures that only /// care about an entry's text. #[cfg(test)] @@ -25,8 +81,8 @@ impl SelectionPropertyKind { Self::Fill | Self::TextBackground => SelectionPropertyValue::Toggle(None), Self::ArrowHead => SelectionPropertyValue::ArrowHead(None), Self::ArrowStyle => SelectionPropertyValue::ArrowStyle(None), - Self::Thickness - | Self::FontSize + Self::Thickness | Self::Opacity => SelectionPropertyValue::Level(None), + Self::FontSize | Self::ArrowLength | Self::ArrowAngle | Self::SpotlightMagnification => SelectionPropertyValue::Number(None), @@ -43,6 +99,8 @@ impl SelectionPropertyKind { #[derive(Debug, Clone, Copy, PartialEq)] pub enum SelectionPropertyValue { Color(Option), + /// A value set on a slider as well as stepped: thickness and opacity. + Level(Option), Number(Option), /// A pressure stroke stores a width per point, so it has no one number. PressureVaries, @@ -132,6 +190,8 @@ pub enum PropertiesPanelHit { }, /// The swatch row's trailing button that opens the full color picker. MoreColors(usize), + /// A slider's track, which follows the pointer while pressed. + Slider(usize), StepDown(usize), StepUp(usize), Toggle(usize), @@ -153,6 +213,7 @@ impl PropertiesPanelHit { Self::Row(row) | Self::Swatch { row, .. } | Self::MoreColors(row) + | Self::Slider(row) | Self::StepDown(row) | Self::StepUp(row) | Self::Toggle(row) @@ -209,8 +270,12 @@ pub enum PropertiesRowControl { down: PanelRect, value: PanelRect, up: PanelRect, - /// A short stroke drawn at the current thickness. - preview: Option, + }, + Slider { + /// The track's hit area; the track itself runs along its middle. + track: PanelRect, + /// The readout to the track's right. + value: PanelRect, }, Toggle { switch: PanelRect, @@ -267,6 +332,11 @@ pub struct PropertiesPanelLayout { pub footer_top: Option, /// Width of the readout between a stepper's − and + buttons. pub stepper_value_width: f64, + /// Where controls beside their label may start, past the widest label. + pub label_column: f64, + /// Width of a slider's readout: room for its widest value, so the + /// track does not shift under the pointer while its value changes. + pub slider_value_width: f64, /// Width of each half of the arrow-head Start/End control. pub head_segment_width: f64, /// Content width of one column of rows. @@ -295,7 +365,7 @@ pub struct ShapePropertiesPanel { pub entries: Vec, pub swatches: Vec, pub actions: PanelActions, - /// The selection's shared color, for the thickness preview stroke. + /// The selection's shared color, which fills its sliders' tracks. pub preview_color: Option, pub hover: Option, /// The part a pointer press landed on; a release activates it only there. diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 9fbc73f0..6f4d80b9 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -92,12 +92,13 @@ pub(crate) fn handle_board_picker_press( pub(crate) fn handle_properties_panel_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, button: MouseButton, points: PointerPoints, ) -> Option { let screen = points.screen(); state - .handle_properties_panel_press(button, screen.x(), screen.y()) + .handle_properties_panel_press_with_measurer(measurer, button, screen.x(), screen.y()) .then_some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } @@ -452,6 +453,7 @@ pub(crate) fn handle_board_picker_motion( pub(crate) fn handle_properties_panel_motion( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> Option { if !state.is_properties_panel_open() { @@ -459,7 +461,7 @@ pub(crate) fn handle_properties_panel_motion( } if state.properties_panel_layout().is_some() { let screen = points.screen(); - state.update_properties_panel_hover_from_pointer(screen.x(), screen.y()); + state.move_properties_panel_pointer_with(measurer, screen.x(), screen.y()); } Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index cb257a2b..a451192a 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -49,7 +49,9 @@ pub(crate) fn route_pointer_press( if let Some(outcome) = adapters::handle_board_picker_press(state, event.button(), points) { return outcome; } - if let Some(outcome) = adapters::handle_properties_panel_press(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_properties_panel_press(state, resources.measurer, event.button(), points) + { return outcome; } if event.button() == MouseButton::Left @@ -109,7 +111,7 @@ pub(crate) fn route_pointer_motion( if let Some(outcome) = adapters::handle_board_picker_motion(state, points) { return outcome; } - if let Some(outcome) = adapters::handle_properties_panel_motion(state, points) { + if let Some(outcome) = adapters::handle_properties_panel_motion(state, measurer, points) { return outcome; } if let Some(outcome) = adapters::handle_active_motion(state, measurer, points) { diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 4f6cd14b..8400939f 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -14,7 +14,7 @@ pub(crate) use core::{ }; pub(crate) use core::{InputEffect, InputEffectDrain}; pub(crate) use core::{ - PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, ShapePropertiesPanel, properties_panel_metrics, }; diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 7dac8754..d57d78b1 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -178,11 +178,27 @@ impl InputState { true } + #[cfg(test)] pub(in crate::input::state) fn handle_properties_panel_press( &mut self, button: MouseButton, x: i32, y: i32, + ) -> bool { + self.handle_properties_panel_press_with_measurer( + &crate::draw::TextMeasurer::default(), + button, + x, + y, + ) + } + + pub(in crate::input::state) fn handle_properties_panel_press_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + button: MouseButton, + x: i32, + y: i32, ) -> bool { if !self.is_properties_panel_open() { return false; @@ -193,7 +209,7 @@ impl InputState { } match button { MouseButton::Left => { - if !self.press_properties_panel_at(x, y) { + if !self.press_properties_panel_at_with(measurer, x, y) { self.close_properties_panel(); } } diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index 7af974bb..0a0de55b 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -158,36 +158,43 @@ fn clicking_the_swatch_the_selection_already_has_is_a_quiet_no_op() { assert!(!state.has_active_toast(), "and no \"No changes\" toast"); } +fn arrow_length_of(state: &InputState, id: ShapeId) -> f64 { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { arrow_length, .. } => *arrow_length, + other => panic!("expected arrow, got {other:?}"), + } +} + #[test] -fn stepper_buttons_step_thickness_down_and_up() { +fn stepper_buttons_step_a_number_down_and_up() { let mut state = create_test_input_state(); - let id = add_rect(&mut state, PALETTE_RED, false); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); + let length = row(&state, "Arrow length"); - click(&mut state, PropertiesPanelHit::StepUp(thickness)); - assert_eq!(rect_of(&state, id).2, 4.0); - click(&mut state, PropertiesPanelHit::StepDown(thickness)); - click(&mut state, PropertiesPanelHit::StepDown(thickness)); - assert_eq!(rect_of(&state, id).2, 2.0); + click(&mut state, PropertiesPanelHit::StepUp(length)); + assert_eq!(arrow_length_of(&state, id), 26.0); + click(&mut state, PropertiesPanelHit::StepDown(length)); + click(&mut state, PropertiesPanelHit::StepDown(length)); + assert_eq!(arrow_length_of(&state, id), 22.0); assert_eq!( - state.properties_panel().unwrap().entries[thickness].value, - "2.0px" + state.properties_panel().unwrap().entries[length].value, + "22px" ); } #[test] fn a_press_that_leaves_its_control_before_the_release_changes_nothing() { let mut state = create_test_input_state(); - let id = add_rect(&mut state, PALETTE_RED, false); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); - let up = point(&state, PropertiesPanelHit::StepUp(thickness)); - let down = point(&state, PropertiesPanelHit::StepDown(thickness)); + let length = row(&state, "Arrow length"); + let up = point(&state, PropertiesPanelHit::StepUp(length)); + let down = point(&state, PropertiesPanelHit::StepDown(length)); click_at(&mut state, up, down); - assert_eq!(rect_of(&state, id).2, 3.0); + assert_eq!(arrow_length_of(&state, id), 24.0); assert!(state.is_properties_panel_open()); } @@ -292,7 +299,7 @@ fn the_lock_button_locks_the_selection_and_its_rows_go_inert() { let id = add_rect(&mut state, PALETTE_RED, false); open(&mut state, vec![id]); let thickness = row(&state, "Thickness"); - let up = point(&state, PropertiesPanelHit::StepUp(thickness)); + let up = point(&state, PropertiesPanelHit::Slider(thickness)); click(&mut state, PropertiesPanelHit::Lock); @@ -354,18 +361,18 @@ fn wheel_over_a_row_steps_it_and_the_rest_of_the_panel_swallows_it() { fn a_click_focuses_its_row_quietly_while_arrow_keys_show_the_ring() { let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); - let id = add_rect(&mut state, PALETTE_RED, false); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); + let length = row(&state, "Arrow length"); - click(&mut state, PropertiesPanelHit::StepUp(thickness)); + click(&mut state, PropertiesPanelHit::StepUp(length)); let panel = state.properties_panel().unwrap(); - assert_eq!(panel.keyboard_focus, Some(thickness)); + assert_eq!(panel.keyboard_focus, Some(length)); assert!(!panel.focus_visible, "a click draws no focus ring"); // The arrow keys continue on the clicked row. assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); - assert_eq!(rect_of(&state, id).2, 5.0); + assert_eq!(arrow_length_of(&state, id), 28.0); assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Down)); assert!(state.properties_panel().unwrap().focus_visible); @@ -460,7 +467,7 @@ fn ok_on_a_mixed_selection_applies_even_the_color_it_opened_on() { } #[test] -fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { +fn an_opaque_swatch_changes_the_hue_and_keeps_the_shapes_opacity() { let mut state = create_test_input_state(); let faint_red = Color { a: 0.25, @@ -469,14 +476,18 @@ fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { let id = add_rect(&mut state, faint_red, false); open(&mut state, vec![id]); let color_row = row(&state, "Color"); - let red = state - .properties_panel() - .unwrap() - .swatches - .iter() - .position(|swatch| swatch.color == PALETTE_RED) - .expect("red swatch"); + let swatch = |state: &InputState, color: Color| { + state + .properties_panel() + .unwrap() + .swatches + .iter() + .position(|swatch| swatch.color == color) + .expect("swatch") + }; + let depth = undo_depth(&state); + let red = swatch(&state, PALETTE_RED); click( &mut state, PropertiesPanelHit::Swatch { @@ -484,8 +495,25 @@ fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { index: red, }, ); + assert_eq!(rect_of(&state, id).0, faint_red, "same hue: nothing to do"); + assert_eq!(undo_depth(&state), depth); - assert_eq!(rect_of(&state, id).0, PALETTE_RED, "now opaque"); + let green = swatch(&state, PALETTE_GREEN); + click( + &mut state, + PropertiesPanelHit::Swatch { + row: color_row, + index: green, + }, + ); + assert_eq!( + rect_of(&state, id).0, + Color { + a: 0.25, + ..PALETTE_GREEN + }, + "the new hue at the shape's own opacity" + ); } #[test] @@ -578,21 +606,17 @@ fn stepping_the_color_tells_opacity_variants_of_one_hue_apart() { seen.push(rect_of(&state, id).0); } + // The translucent swatch sets its opacity; the opaque ones after it + // change the hue and keep that opacity, which the Opacity row owns. + let faint = |color: Color| Color { a: 0.4, ..color }; assert_eq!( seen, - vec![ - Color { - a: 0.4, - ..PALETTE_RED - }, - PALETTE_GREEN, - PALETTE_RED - ] + vec![faint(PALETTE_RED), faint(PALETTE_GREEN), faint(PALETTE_RED)] ); let panel = state.properties_panel().unwrap(); let color_row = row(&state, "Color"); - assert_eq!(panel.entries[color_row].value, "Red"); - assert_eq!(panel.current_swatch(&panel.entries[color_row]), Some(0)); + assert_eq!(panel.entries[color_row].value, "Faint red"); + assert_eq!(panel.current_swatch(&panel.entries[color_row]), Some(1)); } #[test] @@ -720,3 +744,150 @@ fn a_locked_selection_can_be_reordered_but_not_duplicated_or_deleted() { click_at(&mut state, delete, delete); assert_eq!(state.boards.active_frame().shapes.len(), 2); } + +#[test] +fn the_opacity_row_steps_by_five_percent_and_a_marker_stays_translucent() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let rect = add_rect(&mut state, PALETTE_RED, false); + let marker = state + .boards + .active_frame_mut() + .add_shape(Shape::MarkerStroke { + points: vec![(100, 100), (200, 120)], + color: Color { + a: 0.85, + ..PALETTE_RED + }, + thick: 12.0, + }); + open(&mut state, vec![rect, marker]); + let opacity = row(&state, "Opacity"); + assert_eq!( + state.properties_panel().unwrap().entries[opacity].value, + "Mixed" + ); + state.set_properties_panel_focus(Some(opacity)); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); + let marker_alpha = + |state: &InputState| match &state.boards.active_frame().shape(marker).unwrap().shape { + Shape::MarkerStroke { color, .. } => color.a, + other => panic!("expected marker, got {other:?}"), + }; + assert_eq!(rect_of(&state, rect).0.a, 1.0, "already at the top"); + assert!( + (marker_alpha(&state) - 0.9).abs() < 1e-9, + "a marker tops out at 90%" + ); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Left)); + assert!((rect_of(&state, rect).0.a - 0.95).abs() < 1e-9); + assert!((marker_alpha(&state) - 0.85).abs() < 1e-9); +} + +#[test] +fn dragging_the_thickness_slider_follows_the_pointer_and_undoes_in_one_step() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + let y = track.center().1 as i32; + let depth = undo_depth(&state); + + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.right() as i32 - 1, + y + )); + assert_eq!( + rect_of(&state, id).2, + 50.0, + "the value jumps to the pointer" + ); + lay_out(&mut state); + state.move_properties_panel_pointer_with(&measurer, track.x as i32 - 40, y); + assert_eq!( + rect_of(&state, id).2, + 1.0, + "a drag past the end stays at it" + ); + assert_eq!(undo_depth(&state), depth, "nothing recorded mid-drag"); + lay_out(&mut state); + state.move_properties_panel_pointer_with(&measurer, track.center().0 as i32, y); + let middle = rect_of(&state, id).2; + assert!(middle > 20.0 && middle < 30.0, "{middle}"); + assert_eq!( + state.properties_panel().unwrap().entries[thickness].value, + format!("{middle:.1}px"), + "the readout follows the drag" + ); + + state.release_properties_panel_at_with(&measurer, track.center().0 as i32, y); + assert_eq!(undo_depth(&state), depth + 1, "the whole drag is one entry"); + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(rect_of(&state, id).2, 3.0); +} + +#[test] +fn closing_the_panel_mid_drag_keeps_the_value_as_one_undo_step() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let opacity = row(&state, "Opacity"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(opacity), + ) + .expect("track"); + let depth = undo_depth(&state); + + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.x as i32, + track.center().1 as i32 + )); + state.close_properties_panel(); + + assert!((rect_of(&state, id).0.a - 0.05).abs() < 1e-9); + assert_eq!(undo_depth(&state), depth + 1); + assert!(!state.is_properties_slider_dragging()); +} + +#[test] +fn the_picker_sets_opacity_exactly() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let color_row = row(&state, "Color"); + click(&mut state, PropertiesPanelHit::MoreColors(color_row)); + let half_green = Color { + a: 0.5, + ..PALETTE_GREEN + }; + + state.color_picker_popup_set_color(half_green); + state.apply_color_picker_popup(); + + assert_eq!(rect_of(&state, id).0, half_green); +} diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs index 7c11d401..5d8955b1 100644 --- a/src/ui/properties_panel.rs +++ b/src/ui/properties_panel.rs @@ -32,8 +32,8 @@ mod controls; use actions::draw_actions; use controls::{ - ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_stepper, - draw_swatches, draw_switch, + ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_slider, + draw_stepper, draw_swatches, draw_switch, }; /// Wash behind the hovered row: a quieter `BG_HOVER`, so the control under @@ -250,26 +250,34 @@ fn draw_row( }; draw_arrow_styles(engine, ctx, buttons, current, state); } - PropertiesRowControl::Stepper { - down, - value, - up, - preview, - } => { - let thickness = match entry.state { - SelectionPropertyValue::Number(value) => value, - _ => None, - }; + PropertiesRowControl::Stepper { down, value, up } => { draw_stepper( engine, ctx, (*down, *value, *up), entry.stepper_text(), - preview.zip(thickness), - panel.preview_color, state, ); } + PropertiesRowControl::Slider { track, value } => { + let level = match entry.state { + SelectionPropertyValue::Level(level) => level, + _ => None, + }; + if let Some(range) = entry.kind.level_range() { + draw_slider( + engine, + ctx, + *track, + *value, + &entry.value, + level, + range, + panel.preview_color, + state, + ); + } + } PropertiesRowControl::Toggle { switch } => { let on = match entry.state { SelectionPropertyValue::Toggle(on) => on, diff --git a/src/ui/properties_panel/controls.rs b/src/ui/properties_panel/controls.rs index 3638da60..3e40deba 100644 --- a/src/ui/properties_panel/controls.rs +++ b/src/ui/properties_panel/controls.rs @@ -7,10 +7,11 @@ use cairo::FontWeight; use crate::draw::{ArrowStyle, Color}; use crate::input::state::properties_panel_metrics::{ - CAPTION_FONT, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, VALUE_FONT, text_style, + CAPTION_FONT, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SLIDER_THUMB_RADIUS, SLIDER_TRACK_HEIGHT, + VALUE_FONT, text_style, }; use crate::input::state::{ - PanelRect, PropertiesPanelHit, PropertiesPanelLock, PropertiesPanelSwatch, + LevelRange, PanelRect, PropertiesPanelHit, PropertiesPanelLock, PropertiesPanelSwatch, }; use crate::toolbar_icons::draw_arrow_style_preview; use crate::ui::primitives::{checkerboard_behind, draw_rounded_rect}; @@ -196,22 +197,8 @@ pub(super) fn draw_stepper( ctx: &cairo::Context, (down, value, up): (PanelRect, PanelRect, PanelRect), text: &str, - preview: Option<(PanelRect, f64)>, - preview_color: Option, state: ControlState, ) { - if let Some((rect, thickness)) = preview { - let (_, cy) = rect.center(); - let width = thickness.clamp(1.0, rect.height); - let color = preview_color.map_or(TEXT_SECONDARY, |color| rgba(Color { a: 1.0, ..color })); - set_color(ctx, state.fade(color)); - ctx.set_line_width(width); - ctx.set_line_cap(cairo::LineCap::Round); - ctx.move_to(rect.x + width / 2.0, cy); - ctx.line_to(rect.right() - width / 2.0, cy); - let _ = ctx.stroke(); - } - let well = PanelRect::new(down.x, down.y, up.right() - down.x, down.height); set_color(ctx, BG_HOVER_WASH); draw_rounded_rect(ctx, well.x, well.y, well.width, well.height, RADIUS_STD); @@ -260,6 +247,90 @@ pub(super) fn draw_stepper( layout.show_at_baseline(ctx, cx - extents.x_advance() / 2.0, cy + VALUE_FONT * 0.35); } +/// A slider: a track filled up to the thumb in the selection's color, and +/// the readout to its right. A mixed value draws no fill and no thumb; the +/// readout says "Mixed", and a drag sets every shape alike. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_slider( + engine: &UiTextEngine, + ctx: &cairo::Context, + track: PanelRect, + readout: PanelRect, + text: &str, + value: Option, + range: LevelRange, + fill: Option, + state: ControlState, +) { + let (_, cy) = track.center(); + let left = track.x + SLIDER_THUMB_RADIUS; + let right = track.right() - SLIDER_THUMB_RADIUS; + let half = SLIDER_TRACK_HEIGHT / 2.0; + set_color(ctx, state.fade(SWITCH_TRACK_OFF)); + draw_rounded_rect( + ctx, + left - half, + cy - half, + right - left + SLIDER_TRACK_HEIGHT, + SLIDER_TRACK_HEIGHT, + half, + ); + let _ = ctx.fill(); + + if let Some(value) = value { + let thumb = range.thumb_x(track, value); + let fill = fill.map_or(ACCENT_PRIMARY, |color| rgba(Color { a: 1.0, ..color })); + set_color(ctx, state.fade(fill)); + draw_rounded_rect( + ctx, + left - half, + cy - half, + thumb - left + SLIDER_TRACK_HEIGHT, + SLIDER_TRACK_HEIGHT, + half, + ); + let _ = ctx.fill(); + + let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::Slider(_))); + ctx.new_path(); + ctx.arc(thumb, cy, SLIDER_THUMB_RADIUS, 0.0, TAU); + set_color(ctx, state.fade(TEXT_WHITE)); + let _ = ctx.fill_preserve(); + set_color( + ctx, + state.fade(if hovered { + ACCENT_BRIGHT + } else { + with_alpha(ACCENT_PRIMARY, 0.6) + }), + ); + ctx.set_line_width(if hovered { 3.0 } else { 2.0 }); + let _ = ctx.stroke(); + } + + let style = text_style(VALUE_FONT, FontWeight::Normal); + let width = engine + .layout(ctx, style, text, None) + .ink_extents() + .x_advance(); + set_color( + ctx, + if state.enabled { + TEXT_PRIMARY + } else { + TEXT_DISABLED + }, + ); + engine.draw_baseline( + ctx, + style, + text, + readout.right() - width, + readout.center().1 + VALUE_FONT * 0.35, + None, + ); +} + pub(super) fn draw_switch( ctx: &cairo::Context, rect: PanelRect, diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 236e7631..b80d35bc 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -206,6 +206,7 @@ pub(crate) const fn selection_kind_slug(kind: SelectionPropertyKind) -> &'static match kind { SelectionPropertyKind::Color => "color", SelectionPropertyKind::Thickness => "thickness", + SelectionPropertyKind::Opacity => "opacity", SelectionPropertyKind::Fill => "fill", SelectionPropertyKind::FontSize => "font-size", SelectionPropertyKind::ArrowHead => "arrow-head", @@ -227,6 +228,7 @@ pub(crate) const fn selection_control_for_kind(kind: SelectionPropertyKind) -> S | SelectionPropertyKind::ArrowStyle | SelectionPropertyKind::TextBackground => StylePillControl::SelectionCycle(kind), SelectionPropertyKind::Thickness + | SelectionPropertyKind::Opacity | SelectionPropertyKind::FontSize | SelectionPropertyKind::ArrowLength | SelectionPropertyKind::ArrowAngle @@ -267,9 +269,12 @@ impl StylePillSpec { } if state == StylePillState::Selection { + // Opacity stays in the properties panel: the strip is short of + // width, and a selection's opacity is not a mid-demo adjustment. let mut controls: Vec<_> = snapshot .selection_properties .iter() + .filter(|entry| entry.kind != SelectionPropertyKind::Opacity) .map(|entry| selection_control_for_kind(entry.kind)) .collect(); if snapshot.selection_has_text && !plan.drop_style_extras { diff --git a/src/ui/toolbar/model/style_pill/tests/selection.rs b/src/ui/toolbar/model/style_pill/tests/selection.rs index 73916d96..51ef3013 100644 --- a/src/ui/toolbar/model/style_pill/tests/selection.rs +++ b/src/ui/toolbar/model/style_pill/tests/selection.rs @@ -258,3 +258,22 @@ fn allocation_free_queries_match_the_materialized_spec() { ); } } + +#[test] +fn opacity_stays_in_the_properties_panel_and_off_the_pill() { + let mut snapshot = selection_snapshot(); + snapshot.selection_properties.push(selection_entry( + "Opacity", + "100%", + SelectionPropertyKind::Opacity, + false, + )); + + let spec = StylePillSpec::build(&snapshot, &TopStripPlan::unconstrained()); + + assert!(spec.controls().iter().all(|control| !matches!( + control, + StylePillControl::SelectionStepper(SelectionPropertyKind::Opacity) + | StylePillControl::SelectionCycle(SelectionPropertyKind::Opacity) + ))); +} From dfdd286ac3ea74373e7b5f458a0ae20efe7f6e54 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:34:34 +0200 Subject: [PATCH 05/12] feat: give fills their own color, with a no-fill swatch Rectangles, ellipses, and polygons gain an optional fill color. Without one a fill paints with the border color, as every fill did before, and the field is written only when set, so sessions read the same either way. Scaling, grid snapping, and copies carry it along. The properties panel's Fill switch becomes a swatch row: a leading "no fill" swatch, then the quick-color palette. A swatch fills the selection with that color and leaves the border alone; no fill turns the fill off and keeps its color, so turning it back on restores it. A shape has one opacity: an opaque fill swatch takes the shape's, and the Opacity row moves border and fill together. The keyboard and the toolbar pill still toggle the fill on and off. --- README.md | 4 +- .../wayland/clipboard/transfer/tests.rs | 1 + src/backend/wayland/session/tests.rs | 1 + .../state/clipboard/session_paste/tests.rs | 1 + src/backend/wayland/state/pdf_export/tests.rs | 1 + .../state/region_capture/cut_preview/tests.rs | 1 + .../wayland/state/region_capture/render.rs | 1 + .../state/region_capture/tests/picker.rs | 1 + .../state/render/canvas/resource_tests.rs | 2 + src/canvas_export/mod.rs | 7 + src/canvas_export/region.rs | 6 + src/canvas_export/tests/board_grid.rs | 2 + src/draw/dirty.rs | 2 + src/draw/frame/tests/history/basics.rs | 3 + src/draw/frame/tests/history/prune.rs | 1 + src/draw/render/primitives.rs | 81 +++++++++--- src/draw/render/selection.rs | 24 +++- src/draw/render/shapes.rs | 12 +- src/draw/shape/tests.rs | 46 +++++++ src/draw/shape/transform.rs | 6 + src/draw/shape/transform/tests.rs | 3 + src/draw/shape/types.rs | 20 ++- src/input/hit_test/tests.rs | 8 ++ src/input/state/core/editing/tests.rs | 1 + src/input/state/core/font_cycle.rs | 1 + src/input/state/core/history.rs | 1 + src/input/state/core/index/grid/tests.rs | 1 + .../state/core/index/measurement_tests.rs | 1 + src/input/state/core/index/owner.rs | 1 + src/input/state/core/modal.rs | 2 + src/input/state/core/properties/apply.rs | 20 ++- .../apply_selection/actions/color.rs | 20 ++- .../apply_selection/actions/fill.rs | 102 ++++++++++++++- .../apply_selection/actions/text.rs | 1 + .../apply_selection/helpers/tests.rs | 1 + src/input/state/core/properties/entries.rs | 15 ++- src/input/state/core/properties/panel.rs | 2 + .../core/properties/panel_layout/focus.rs | 1 + .../core/properties/panel_layout/geometry.rs | 54 ++++++-- .../core/properties/panel_layout/layout.rs | 2 +- .../core/properties/panel_layout/tests.rs | 12 +- src/input/state/core/properties/summary.rs | 26 +++- src/input/state/core/properties/types.rs | 13 +- src/input/state/core/recognition_chip.rs | 1 + src/input/state/core/selection/clipboard.rs | 1 + src/input/state/core/toolbar/apply/mod.rs | 1 + .../state/core/utility/restore_notice.rs | 1 + src/input/state/core/utility/toasts.rs | 1 + src/input/state/interaction/mod.rs | 1 + src/input/state/mouse/press/polygon.rs | 1 + src/input/state/mouse/release/selection.rs | 1 + src/input/state/tests/boards.rs | 1 + src/input/state/tests/delete_restore.rs | 2 + src/input/state/tests/drawing.rs | 1 + src/input/state/tests/erase.rs | 3 + src/input/state/tests/hit_testing.rs | 8 ++ src/input/state/tests/menus/clipboard.rs | 1 + src/input/state/tests/menus/context_menu.rs | 12 ++ src/input/state/tests/menus/history.rs | 3 + src/input/state/tests/menus/locks.rs | 4 + src/input/state/tests/properties_panel.rs | 6 +- .../state/tests/properties_panel_controls.rs | 123 ++++++++++++++++-- src/input/state/tests/selection/actions.rs | 5 + src/input/state/tests/selection/damage.rs | 1 + src/input/state/tests/selection/deletion.rs | 3 + src/input/state/tests/selection/duplicate.rs | 26 ++++ src/input/state/tests/selection/reorder.rs | 1 + src/input/state/tests/tool_controls.rs | 1 + src/input/state/tests/transform.rs | 8 ++ src/input/tool/drawing.rs | 4 + src/input/tool/drawing/polygon.rs | 2 + src/input/tool/live_shape.rs | 1 + src/input/tool/live_shape/ellipse.rs | 1 + src/input/tool/live_shape/grid.rs | 6 + src/input/tool/live_shape/rough_rectangle.rs | 1 + src/input/tool/live_shape/triangle.rs | 1 + src/session/snapshot/tests.rs | 1 + src/session/storage/tests.rs | 1 + src/session/tests/history.rs | 1 + src/session/tests/limits.rs | 1 + src/session/tests/roundtrip.rs | 2 + .../page_panel/thumbnail/cache/tests.rs | 1 + .../page_panel/thumbnail/content.rs | 1 + src/ui/properties_panel.rs | 17 ++- src/ui/properties_panel/controls.rs | 62 ++++++++- src/ui/status/bar/tests/width_budget.rs | 1 + tests/ui.rs | 1 + 87 files changed, 754 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 00b4132c..c5939a6a 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,12 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Freehand pen, highlighter, eraser (circle/rect) - Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink; a brief chip such as "Circle · Ctrl+Z keeps ink" names each recognized shape (`[drawing] shape_recognition_feedback`). Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition in the style pill's **Pen feel** panel (or its **Shapes** meter or **Detect** stepper, per `[ui.toolbar] stroke_controls`), `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. - Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the style pill's **Pen feel** chip, which opens a panel with a smoothing meter and a live preview of the result; `[ui.toolbar] stroke_controls` swaps it for inline **Smooth** meters or steppers); tablet pressure values are preserved, and the level is remembered with the session -- Shapes: lines, rectangles, ellipses, polygons (with fill toggle) +- Shapes: lines, rectangles, ellipses, polygons (with fill toggle; the properties panel gives a fill its own color) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) -- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time +- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text diff --git a/src/backend/wayland/clipboard/transfer/tests.rs b/src/backend/wayland/clipboard/transfer/tests.rs index c7110914..5866d092 100644 --- a/src/backend/wayland/clipboard/transfer/tests.rs +++ b/src/backend/wayland/clipboard/transfer/tests.rs @@ -222,6 +222,7 @@ fn rect() -> Shape { w: 10, h: 10, fill: false, + fill_color: None, color: crate::draw::BLACK, thick: 1.0, } diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 9f2813c9..8e317229 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1447,6 +1447,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio w: 10, h: 10, fill: false, + fill_color: None, color: input.style.current_color, thick: input.style.current_thickness, }); diff --git a/src/backend/wayland/state/clipboard/session_paste/tests.rs b/src/backend/wayland/state/clipboard/session_paste/tests.rs index d00a474f..104e937c 100644 --- a/src/backend/wayland/state/clipboard/session_paste/tests.rs +++ b/src/backend/wayland/state/clipboard/session_paste/tests.rs @@ -59,6 +59,7 @@ fn paste_preflight_skips_stale_target_page_generation() { w: 3, h: 4, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs index 776c730d..cdafa84e 100644 --- a/src/backend/wayland/state/pdf_export/tests.rs +++ b/src/backend/wayland/state/pdf_export/tests.rs @@ -156,6 +156,7 @@ fn fit_content_snapshot_uses_content_bounds() { w: 100, h: 50, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/backend/wayland/state/region_capture/cut_preview/tests.rs b/src/backend/wayland/state/region_capture/cut_preview/tests.rs index df89d9e5..4619f87d 100644 --- a/src/backend/wayland/state/region_capture/cut_preview/tests.rs +++ b/src/backend/wayland/state/region_capture/cut_preview/tests.rs @@ -907,6 +907,7 @@ fn render_source_jobs_paint_annotations_before_applying_key_cuts_on_the_worker() w: 8, h: 8, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/backend/wayland/state/region_capture/render.rs b/src/backend/wayland/state/region_capture/render.rs index 79033791..9f634c48 100644 --- a/src/backend/wayland/state/region_capture/render.rs +++ b/src/backend/wayland/state/region_capture/render.rs @@ -165,6 +165,7 @@ mod tests { w: 3, h: 2, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs index bdb872b8..0f439248 100644 --- a/src/backend/wayland/state/region_capture/tests/picker.rs +++ b/src/backend/wayland/state/region_capture/tests/picker.rs @@ -135,6 +135,7 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { w: 3, h: 2, fill: true, + fill_color: None, color: crate::draw::RED, thick: 1.0, }); diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs index c7272214..addad121 100644 --- a/src/backend/wayland/state/render/canvas/resource_tests.rs +++ b/src/backend/wayland/state/render/canvas/resource_tests.rs @@ -199,6 +199,7 @@ fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { }, thick: 2.0, fill: true, + fill_color: None, }); } assert!(layer.ensure(&measurer, &mut caches, &shapes, request)); @@ -327,6 +328,7 @@ fn each_scene_key_rebakes_without_shape_identity_changes() { }, thick: 2.0, fill: true, + fill_color: None, }); } assert!(layer.ensure(&measurer, &mut caches, &scene, changed)); diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index c8395842..6fd95e1f 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -88,6 +88,7 @@ mod tests { w: 8, h: 8, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -115,6 +116,7 @@ mod tests { w: 4, h: 4, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -189,6 +191,7 @@ mod tests { w: 2, h: 2, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -351,6 +354,7 @@ mod tests { w: 2, h: 4, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -468,6 +472,7 @@ mod tests { w: 6, h: 6, fill: true, + fill_color: None, color: BLACK, thick: 1.0, }); @@ -506,6 +511,7 @@ mod tests { w: 12, h: 12, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -541,6 +547,7 @@ mod tests { w: 12, h: 12, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index 06aaaf5a..80c6dfc4 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -355,6 +355,7 @@ mod tests { w: 1, h: 1, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -364,6 +365,7 @@ mod tests { w: 10, h: 10, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -392,6 +394,7 @@ mod tests { w: 1, h: 1, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -550,6 +553,7 @@ mod tests { w: 4, h: 4, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -654,6 +658,7 @@ mod tests { w: 1, h: 1, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -690,6 +695,7 @@ mod tests { w: 8, h: 1, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/canvas_export/tests/board_grid.rs b/src/canvas_export/tests/board_grid.rs index c4a3d0e8..051a6c6f 100644 --- a/src/canvas_export/tests/board_grid.rs +++ b/src/canvas_export/tests/board_grid.rs @@ -34,6 +34,7 @@ fn board_grid_png_erasers_restore_pattern_and_snapshot_is_independent() { w: 20, h: 20, fill: true, + fill_color: None, color: RED, thick: 1.0, }); @@ -142,6 +143,7 @@ fn board_grid_pdf_stays_vector_without_erasers_and_leaves_margins_plain() { w: 8, h: 8, fill: true, + fill_color: None, color: RED, thick: 1.0, }); diff --git a/src/draw/dirty.rs b/src/draw/dirty.rs index 8d23793c..d31526f5 100644 --- a/src/draw/dirty.rs +++ b/src/draw/dirty.rs @@ -162,6 +162,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, @@ -177,6 +178,7 @@ mod tests { w: 15, h: 15, fill: false, + fill_color: None, color: Color { r: 0.0, g: 1.0, diff --git a/src/draw/frame/tests/history/basics.rs b/src/draw/frame/tests/history/basics.rs index 64276ff7..4161e559 100644 --- a/src/draw/frame/tests/history/basics.rs +++ b/src/draw/frame/tests/history/basics.rs @@ -8,6 +8,7 @@ fn rect_at(x: i32) -> Shape { w: 10, h: 10, fill: false, + fill_color: None, color: BLACK, thick: 2.0, } @@ -118,6 +119,7 @@ fn adding_new_shape_clears_redo_stack() { w: 10, h: 10, fill: false, + fill_color: None, color: BLACK, thick: 2.0, }; @@ -140,6 +142,7 @@ fn adding_new_shape_clears_redo_stack() { w: 15, h: 15, fill: false, + fill_color: None, color: BLACK, thick: 2.0, }; diff --git a/src/draw/frame/tests/history/prune.rs b/src/draw/frame/tests/history/prune.rs index 0546264c..c50e5c07 100644 --- a/src/draw/frame/tests/history/prune.rs +++ b/src/draw/frame/tests/history/prune.rs @@ -11,6 +11,7 @@ fn prune_history_for_removed_ids_prunes_shapes_and_actions() { w: 10, h: 10, fill: false, + fill_color: None, color: BLACK, thick: 1.0, }; diff --git a/src/draw/render/primitives.rs b/src/draw/render/primitives.rs index 0f97648b..8c2a502b 100644 --- a/src/draw/render/primitives.rs +++ b/src/draw/render/primitives.rs @@ -20,7 +20,18 @@ pub(super) fn render_line( let _ = ctx.stroke(); } -/// Render a rectangle (outline) +/// Fill the current path with `fill`, if any, and keep the path for the +/// outline stroke that follows. +fn fill_path(ctx: &cairo::Context, fill: Option) { + if let Some(paint) = fill { + let _ = ctx.save(); + ctx.set_source_rgba(paint.r, paint.g, paint.b, paint.a); + let _ = ctx.fill_preserve(); + let _ = ctx.restore(); + } +} + +/// Render a rectangle outline, filled with `fill` when there is one. #[allow(clippy::too_many_arguments)] pub(super) fn render_rect( ctx: &cairo::Context, @@ -28,7 +39,7 @@ pub(super) fn render_rect( y: i32, w: i32, h: i32, - fill: bool, + fill: Option, color: Color, thick: f64, ) { @@ -41,12 +52,7 @@ pub(super) fn render_rect( let (norm_x, norm_y, norm_w, norm_h) = util::normalize_i32_rect(x, y, w, h); ctx.rectangle(norm_x, norm_y, norm_w, norm_h); - if fill { - let _ = ctx.save(); - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - let _ = ctx.fill_preserve(); - let _ = ctx.restore(); - } + fill_path(ctx, fill); let _ = ctx.stroke(); } @@ -58,7 +64,7 @@ pub(super) fn render_ellipse( cy: i32, rx: i32, ry: i32, - fill: bool, + fill: Option, color: Color, thick: f64, ) { @@ -74,12 +80,7 @@ pub(super) fn render_ellipse( ctx.scale(rx as f64, ry as f64); ctx.new_sub_path(); ctx.arc(0.0, 0.0, 1.0, 0.0, 2.0 * std::f64::consts::PI); - if fill { - let _ = ctx.save(); - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - let _ = ctx.fill_preserve(); - ctx.restore().ok(); - } + fill_path(ctx, fill); ctx.restore().ok(); let _ = ctx.stroke(); @@ -90,7 +91,7 @@ pub(super) fn render_ellipse( pub(super) fn render_polygon( ctx: &cairo::Context, points: &[(i32, i32)], - fill: bool, + fill: Option, color: Color, thick: f64, ) { @@ -109,9 +110,7 @@ pub(super) fn render_polygon( ctx.line_to(x as f64, y as f64); } ctx.close_path(); - if fill { - let _ = ctx.fill_preserve(); - } + fill_path(ctx, fill); let _ = ctx.stroke(); let _ = ctx.restore(); } @@ -515,7 +514,7 @@ mod tests { }; ctx.move_to(10.0, 90.0); - render_ellipse(&ctx, 80, 20, 20, 10, false, magenta, 6.0); + render_ellipse(&ctx, 80, 20, 20, 10, None, magenta, 6.0); drop(ctx); assert_eq!( @@ -545,9 +544,49 @@ mod tests { i32::MIN, i32::MIN, i32::MIN, - true, + Some(color), color, 1.0, ); } + + fn rgb_at(surface: &mut ImageSurface, x: i32, y: i32) -> (u8, u8, u8) { + let stride = surface.stride() as usize; + let offset = y as usize * stride + x as usize * 4; + let data = surface.data().unwrap(); + // ARGB32 is stored little-endian as B, G, R, A. + (data[offset + 2], data[offset + 1], data[offset]) + } + + #[test] + fn a_fill_color_paints_the_inside_and_the_border_keeps_its_own() { + let (mut surface, ctx) = surface_with_context(60, 60); + let red = Color::new(1.0, 0.0, 0.0, 1.0); + let blue = Color::new(0.0, 0.0, 1.0, 1.0); + crate::draw::render_shape( + &ctx, + &crate::draw::Shape::Rect { + x: 10, + y: 10, + w: 40, + h: 40, + fill: true, + fill_color: Some(blue), + color: red, + thick: 4.0, + }, + ); + + drop(ctx); + assert_eq!( + rgb_at(&mut surface, 30, 30), + (0, 0, 255), + "inside is the fill" + ); + assert_eq!( + rgb_at(&mut surface, 10, 30), + (255, 0, 0), + "the border is red" + ); + } } diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 5d29b476..807d8b27 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -71,7 +71,16 @@ pub fn render_selection_halo_with_measurer( fill, .. } => { - render_rect(ctx, *x, *y, *w, *h, *fill, glow, thick + outline_width); + render_rect( + ctx, + *x, + *y, + *w, + *h, + fill.then_some(glow), + glow, + thick + outline_width, + ); } Shape::Ellipse { cx, @@ -82,7 +91,16 @@ pub fn render_selection_halo_with_measurer( thick, .. } => { - render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, glow, thick + outline_width); + render_ellipse( + ctx, + *cx, + *cy, + *rx, + *ry, + fill.then_some(glow), + glow, + thick + outline_width, + ); } Shape::Spotlight { cx, cy, rx, ry, .. } => { // The spotlight itself paints nothing, so the halo is the only way to @@ -101,7 +119,7 @@ pub fn render_selection_halo_with_measurer( ); } Shape::Polygon { points, thick, .. } => { - render_polygon(ctx, points, false, glow, thick + outline_width); + render_polygon(ctx, points, None, glow, thick + outline_width); } Shape::Arrow { x1, diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index ce13a0ef..66ffe536 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -121,10 +121,12 @@ pub(super) fn render_shape_with_cache( w, h, fill, + fill_color, color, thick, } => { - render_rect(ctx, *x, *y, *w, *h, *fill, *color, *thick); + let paint = fill.then(|| fill_color.unwrap_or(*color)); + render_rect(ctx, *x, *y, *w, *h, paint, *color, *thick); } Shape::Ellipse { cx, @@ -132,19 +134,23 @@ pub(super) fn render_shape_with_cache( rx, ry, fill, + fill_color, color, thick, } => { - render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, *color, *thick); + let paint = fill.then(|| fill_color.unwrap_or(*color)); + render_ellipse(ctx, *cx, *cy, *rx, *ry, paint, *color, *thick); } Shape::Polygon { points, fill, + fill_color, color, thick, .. } => { - render_polygon(ctx, points, *fill, *color, *thick); + let paint = fill.then(|| fill_color.unwrap_or(*color)); + render_polygon(ctx, points, paint, *color, *thick); } Shape::Arrow { x1, diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index 68ea653d..da8739fa 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -241,6 +241,7 @@ fn ellipse_bounding_box_handles_radii_and_stroke() { rx: 40, ry: 20, fill: false, + fill_color: None, color: WHITE, thick: 2.0, }; @@ -289,6 +290,7 @@ fn polygon_bounding_box_covers_vertices_and_stroke() { kind: PolygonKind::Triangle, points: vec![(10, 20), (30, 40), (5, 35)], fill: false, + fill_color: None, color: WHITE, thick: 6.0, }; @@ -306,6 +308,7 @@ fn polygon_shape_serializes_and_deserializes_with_points() { kind: PolygonKind::Regular { sides: 6 }, points: vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)], fill: true, + fill_color: None, color: WHITE, thick: 4.0, }; @@ -318,10 +321,12 @@ fn polygon_shape_serializes_and_deserializes_with_points() { kind, points, fill, + fill_color, color, thick, } => { assert_eq!(kind, PolygonKind::Regular { sides: 6 }); + assert_eq!(fill_color, None); assert_eq!( points, vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)] @@ -340,6 +345,7 @@ fn invalid_polygon_has_no_bounds() { kind: PolygonKind::Freeform, points: vec![(10, 20), (10, 20), (30, 40)], fill: false, + fill_color: None, color: WHITE, thick: 6.0, }; @@ -631,3 +637,43 @@ fn embedded_image_clones_share_the_encoded_payload() { assert!(std::sync::Arc::ptr_eq(&image.bytes, &cloned.bytes)); } + +#[test] +fn a_fill_color_is_written_only_when_set_and_old_files_read_without_one() { + let old = r#"{"Rect":{"x":1,"y":2,"w":30,"h":40,"fill":true,"color":{"r":1.0,"g":0.0,"b":0.0,"a":1.0},"thick":2.0}}"#; + let shape: Shape = serde_json::from_str(old).expect("a rect saved before fill colors"); + match &shape { + Shape::Rect { + fill, fill_color, .. + } => { + assert!(*fill); + assert_eq!(*fill_color, None); + } + other => panic!("expected rect, got {other:?}"), + } + let json = serde_json::to_string(&shape).expect("serialize"); + assert!( + !json.contains("fill_color"), + "an unset fill color stays out: {json}" + ); + + let blue = crate::draw::Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }; + let filled = Shape::Ellipse { + cx: 10, + cy: 10, + rx: 5, + ry: 5, + fill: true, + fill_color: Some(blue), + color: WHITE, + thick: 2.0, + }; + let restored: Shape = + serde_json::from_str(&serde_json::to_string(&filled).expect("serialize")).expect("read"); + assert_eq!(restored, filled); +} diff --git a/src/draw/shape/transform.rs b/src/draw/shape/transform.rs index ffce4908..507a5223 100644 --- a/src/draw/shape/transform.rs +++ b/src/draw/shape/transform.rs @@ -90,6 +90,7 @@ impl Shape { w, h, fill, + fill_color, color, thick, } => { @@ -102,6 +103,7 @@ impl Shape { w: nw.max(1), h: nh.max(1), fill: *fill, + fill_color: *fill_color, color: *color, thick: *thick, } @@ -112,6 +114,7 @@ impl Shape { rx, ry, fill, + fill_color, color, thick, } => { @@ -124,6 +127,7 @@ impl Shape { rx: nrx.max(1), ry: nry.max(1), fill: *fill, + fill_color: *fill_color, color: *color, thick: *thick, } @@ -210,6 +214,7 @@ impl Shape { kind, points, fill, + fill_color, color, thick, } => { @@ -218,6 +223,7 @@ impl Shape { kind: *kind, points: scaled_points, fill: *fill, + fill_color: *fill_color, color: *color, thick: *thick, } diff --git a/src/draw/shape/transform/tests.rs b/src/draw/shape/transform/tests.rs index 1bfd245c..51cd932f 100644 --- a/src/draw/shape/transform/tests.rs +++ b/src/draw/shape/transform/tests.rs @@ -46,6 +46,7 @@ fn every_shape_variant_translates_its_bounds() { w: 30, h: 40, fill: false, + fill_color: None, color: WHITE, thick: 2.0, }, @@ -55,6 +56,7 @@ fn every_shape_variant_translates_its_bounds() { rx: 10, ry: 15, fill: false, + fill_color: None, color: WHITE, thick: 2.0, }, @@ -62,6 +64,7 @@ fn every_shape_variant_translates_its_bounds() { kind: PolygonKind::Freeform, points: vec![(10, 20), (30, 20), (20, 40)], fill: true, + fill_color: None, color: WHITE, thick: 2.0, }, diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index da0d451c..f6f360ae 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -238,6 +238,12 @@ pub enum Shape { h: i32, /// Whether to fill the rectangle fill: bool, + /// Fill paint while `fill` is on. `None` fills with the border color, + /// as every fill did before fills had their own color; it is only + /// written when set, so older sessions and newer ones read the same. + /// Kept while `fill` is off, so turning the fill back on restores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill_color: Option, /// Border color color: Color, /// Border thickness in pixels @@ -255,6 +261,12 @@ pub enum Shape { ry: i32, /// Whether to fill the ellipse fill: bool, + /// Fill paint while `fill` is on. `None` fills with the border color, + /// as every fill did before fills had their own color; it is only + /// written when set, so older sessions and newer ones read the same. + /// Kept while `fill` is off, so turning the fill back on restores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill_color: Option, /// Border color color: Color, /// Border thickness in pixels @@ -268,7 +280,13 @@ pub enum Shape { points: Vec<(i32, i32)>, /// Whether to fill the polygon. fill: bool, - /// Border/fill color. + /// Fill paint while `fill` is on. `None` fills with the border color, + /// as every fill did before fills had their own color; it is only + /// written when set, so older sessions and newer ones read the same. + /// Kept while `fill` is off, so turning the fill back on restores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill_color: Option, + /// Border color, and the fill's unless `fill_color` is set. color: Color, /// Border thickness in pixels. thick: f64, diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs index 1a767a4e..1419c663 100644 --- a/src/input/hit_test/tests.rs +++ b/src/input/hit_test/tests.rs @@ -34,6 +34,7 @@ fn compute_hit_bounds_inflates_bounds_for_tolerance() { w: 30, h: 40, fill: false, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -108,6 +109,7 @@ fn rect_hit_handles_degenerate_dimensions() { w: 0, h: 20, fill: false, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -129,6 +131,7 @@ fn ellipse_hit_handles_zero_radius() { rx: 0, ry: 0, fill: false, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -148,6 +151,7 @@ fn polygon_hit_tests_closed_outline_only() { kind: PolygonKind::Triangle, points: vec![(10, 10), (40, 10), (25, 40)], fill: true, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -172,6 +176,7 @@ fn point_targeting_hits_filled_rect_and_ellipse_interiors() { w: 40, h: 30, fill: true, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -186,6 +191,7 @@ fn point_targeting_hits_filled_rect_and_ellipse_interiors() { rx: 20, ry: 12, fill: true, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -207,6 +213,7 @@ fn point_targeting_hits_filled_polygon_interior() { kind: PolygonKind::Triangle, points: vec![(10, 10), (40, 10), (25, 40)], fill: true, + fill_color: None, color: BLACK, thick: 2.0, }, @@ -228,6 +235,7 @@ fn invalid_polygon_hit_test_is_false() { kind: PolygonKind::Freeform, points: vec![(10, 10), (10, 10), (40, 10)], fill: false, + fill_color: None, color: BLACK, thick: 2.0, }, diff --git a/src/input/state/core/editing/tests.rs b/src/input/state/core/editing/tests.rs index 293f7b41..10f54291 100644 --- a/src/input/state/core/editing/tests.rs +++ b/src/input/state/core/editing/tests.rs @@ -7,6 +7,7 @@ fn rectangle(x: i32) -> Shape { w: 20, h: 20, fill: false, + fill_color: None, color: crate::draw::WHITE, thick: 2.0, } diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs index 3d522217..83adca45 100644 --- a/src/input/state/core/font_cycle.rs +++ b/src/input/state/core/font_cycle.rs @@ -242,6 +242,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), thick: 2.0, }); diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 4fee603c..9e2938a7 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -132,6 +132,7 @@ mod tests { w: 10, h: 12, fill: false, + fill_color: None, color: Color { r: 0.2, g: 0.4, diff --git a/src/input/state/core/index/grid/tests.rs b/src/input/state/core/index/grid/tests.rs index de798ca7..82fd93b8 100644 --- a/src/input/state/core/index/grid/tests.rs +++ b/src/input/state/core/index/grid/tests.rs @@ -13,6 +13,7 @@ fn filled_rect(x: i32, y: i32, width: i32, height: i32) -> Shape { w: width, h: height, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/input/state/core/index/measurement_tests.rs b/src/input/state/core/index/measurement_tests.rs index 315428d2..36b1ac7a 100644 --- a/src/input/state/core/index/measurement_tests.rs +++ b/src/input/state/core/index/measurement_tests.rs @@ -175,6 +175,7 @@ fn explicit_point_targeting_keeps_fill_interiors_out_of_stroke_erasing() { w: 100, h: 100, fill: true, + fill_color: None, color: RED, thick: 2.0, }); diff --git a/src/input/state/core/index/owner.rs b/src/input/state/core/index/owner.rs index 51c71233..493554e5 100644 --- a/src/input/state/core/index/owner.rs +++ b/src/input/state/core/index/owner.rs @@ -404,6 +404,7 @@ mod canvas_index_owner_tests { color: Color::new(0.0, 0.0, 0.0, 1.0), thick: 2.0, fill: false, + fill_color: None, }); } frame diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 4261d39b..526cd9fc 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -392,6 +392,7 @@ mod wheel_tests { w: 10, h: 10, fill: false, + fill_color: None, color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), thick: 2.0, }); @@ -416,6 +417,7 @@ mod wheel_tests { w: 10, h: 10, fill: false, + fill_color: None, color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), thick: 2.0, }); diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 98e18666..71ec3d82 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -41,8 +41,14 @@ impl InputState { else { return false; }; - self.set_selection_color_from_panel(measurer, color) + match entry.kind { + SelectionPropertyKind::Fill => { + self.set_selection_fill_from_panel(measurer, Some(color)) + } + _ => self.set_selection_color_from_panel(measurer, color), + } } + PropertiesPanelHit::NoFill(_) => self.set_selection_fill_from_panel(measurer, None), PropertiesPanelHit::MoreColors(_) => { return self.open_color_picker_popup_for_selection_with_measurer(measurer); } @@ -128,6 +134,18 @@ impl InputState { self.apply_selection_color_value_with(measurer, color) } + fn set_selection_fill_from_panel( + &mut self, + measurer: &TextMeasurer, + paint: Option, + ) -> bool { + if !self.selection_fill_paint_changes(paint) { + return false; + } + self.finish_active_arrow_bend(); + self.apply_selection_fill_paint_with(measurer, paint) + } + fn set_selection_arrow_style_from_panel( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 3cbd5e6c..79187cf9 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -94,8 +94,25 @@ fn set_opacity(shape: &mut Shape, opacity: impl FnOnce(f64) -> f64) -> Option f64::EPSILON; + let mut changed = (next - color.a).abs() > f64::EPSILON; color.a = next; + // One opacity per shape: a fill with its own color follows the border. + if let Shape::Rect { + fill_color: Some(fill), + .. + } + | Shape::Ellipse { + fill_color: Some(fill), + .. + } + | Shape::Polygon { + fill_color: Some(fill), + .. + } = shape + { + changed |= (next - fill.a).abs() > f64::EPSILON; + fill.a = next; + } Some(changed) } @@ -277,6 +294,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color, thick: 2.0, }) diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs index f8ba5f22..bcd0fc35 100644 --- a/src/input/state/core/properties/apply_selection/actions/fill.rs +++ b/src/input/state/core/properties/apply_selection/actions/fill.rs @@ -1,5 +1,5 @@ -use crate::draw::Shape; use crate::draw::TextMeasurer; +use crate::draw::{Color, Shape}; use crate::input::state::core::base::InputState; use crate::input::state::{Toast, ToastPriority}; @@ -52,6 +52,104 @@ impl InputState { self.report_selection_apply_result(result, "fill") } + + /// Fills every editable selected closed shape with `paint`, or turns the + /// fill off for `None`. An opaque swatch fills at the shape's own + /// opacity, the way the border's swatches keep it; a translucent one + /// brings its own. Turning the fill off keeps the color, so turning it + /// back on restores it. + pub(in crate::input::state::core::properties) fn apply_selection_fill_paint_with( + &mut self, + measurer: &TextMeasurer, + paint: Option, + ) -> bool { + let result = self.apply_selection_change_with( + measurer, + |shape| filled(shape, paint).is_some(), + |shape| { + let Some(next) = filled(shape, paint) else { + return false; + }; + let Some((fill, fill_color)) = fill_fields(shape) else { + return false; + }; + if (*fill, *fill_color) == next { + return false; + } + (*fill, *fill_color) = next; + true + }, + ); + + self.report_selection_apply_result(result, "fill") + } + + /// Whether filling the selection with `paint` would change any shape it + /// may edit, so picking the fill a selection already has is quiet. + pub(crate) fn selection_fill_paint_changes(&self, paint: Option) -> bool { + let frame = self.boards.active_frame(); + self.selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .filter(|drawn| !drawn.locked) + .filter_map(|drawn| filled(&drawn.shape, paint).zip(fill_state(&drawn.shape))) + .any(|(next, current)| next != current) + } +} + +/// A closed shape's `(fill, fill_color)`, or `None` for a shape with no fill. +fn fill_state(shape: &Shape) -> Option<(bool, Option)> { + match shape { + Shape::Rect { + fill, fill_color, .. + } + | Shape::Ellipse { + fill, fill_color, .. + } + | Shape::Polygon { + fill, fill_color, .. + } => Some((*fill, *fill_color)), + _ => None, + } +} + +/// The `(fill, fill_color)` a fill with `paint` leaves on `shape`, or `None` +/// for a shape that has no fill. +fn filled(shape: &Shape, paint: Option) -> Option<(bool, Option)> { + let (current_fill_color, border) = match shape { + Shape::Rect { + fill_color, color, .. + } + | Shape::Ellipse { + fill_color, color, .. + } + | Shape::Polygon { + fill_color, color, .. + } => (*fill_color, *color), + _ => return None, + }; + Some(match paint { + None => (false, current_fill_color), + Some(swatch) => { + let alpha = if swatch.a >= 1.0 { border.a } else { swatch.a }; + (true, Some(Color { a: alpha, ..swatch })) + } + }) +} + +fn fill_fields(shape: &mut Shape) -> Option<(&mut bool, &mut Option)> { + match shape { + Shape::Rect { + fill, fill_color, .. + } + | Shape::Ellipse { + fill, fill_color, .. + } + | Shape::Polygon { + fill, fill_color, .. + } => Some((fill, fill_color)), + _ => None, + } } #[cfg(test)] @@ -78,6 +176,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); @@ -87,6 +186,7 @@ mod tests { rx: 6, ry: 7, fill: true, + fill_color: None, color: state.style.current_color, thick: 2.0, }); diff --git a/src/input/state/core/properties/apply_selection/actions/text.rs b/src/input/state/core/properties/apply_selection/actions/text.rs index 7dd46272..e29c74b2 100644 --- a/src/input/state/core/properties/apply_selection/actions/text.rs +++ b/src/input/state/core/properties/apply_selection/actions/text.rs @@ -100,6 +100,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); diff --git a/src/input/state/core/properties/apply_selection/helpers/tests.rs b/src/input/state/core/properties/apply_selection/helpers/tests.rs index 0825bb2b..f0457eec 100644 --- a/src/input/state/core/properties/apply_selection/helpers/tests.rs +++ b/src/input/state/core/properties/apply_selection/helpers/tests.rs @@ -26,6 +26,7 @@ fn add_rect( w: 30, h: 40, fill, + fill_color: None, color, thick: 2.0, }); diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index d7da7a7a..d49ddd49 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use super::summary::{ PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_arrow_style, - shape_color, shape_fill, shape_font_size, shape_opacity, shape_spotlight_magnification, + shape_color, shape_fill_paint, shape_font_size, shape_opacity, shape_spotlight_magnification, shape_text_background, shape_thickness, summarize_property, }; use super::types::{SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue}; @@ -146,14 +146,19 @@ impl InputState { )); } - let fill_summary = summarize_property(frame, ids, shape_fill, |a, b| a == b); + let fill_summary = summarize_property(frame, ids, shape_fill_paint, |a, b| match (a, b) { + (Some(a), Some(b)) => color_rgba_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + }); if fill_summary.applicable { entries.push(entry( "Fill", SelectionPropertyKind::Fill, &fill_summary, - |v| if v { "On" } else { "Off" }.to_string(), - SelectionPropertyValue::Toggle, + |paint| { + paint.map_or_else(|| "None".to_string(), |color| color_label(palette, color)) + }, + SelectionPropertyValue::Fill, )); } @@ -268,6 +273,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, @@ -282,6 +288,7 @@ mod tests { w: 10, h: 10, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index b6e3d475..817c3f55 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -294,6 +294,7 @@ mod tests { w, h, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) @@ -426,6 +427,7 @@ mod tests { rx: 10, ry: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs index 604d7514..31b060ad 100644 --- a/src/input/state/core/properties/panel_layout/focus.rs +++ b/src/input/state/core/properties/panel_layout/focus.rs @@ -138,6 +138,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index 4619702d..01aeea93 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -29,7 +29,9 @@ enum RowShape { fn row_shape(entry: &SelectionPropertyEntry) -> RowShape { match entry.state { - SelectionPropertyValue::Color(_) | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block, + SelectionPropertyValue::Color(_) + | SelectionPropertyValue::Fill(_) + | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block, SelectionPropertyValue::Level(_) | SelectionPropertyValue::Number(_) | SelectionPropertyValue::PressureVaries @@ -54,7 +56,7 @@ pub(in crate::input::state::core::properties) fn row_height( swatches: usize, ) -> f64 { let control_height = match entry.state { - SelectionPropertyValue::Color(_) => { + SelectionPropertyValue::Color(_) | SelectionPropertyValue::Fill(_) => { let lines = swatch_lines(swatches) as f64; lines * SWATCH_SIZE + (lines - 1.0) * SWATCH_LINE_GAP } @@ -231,7 +233,10 @@ impl PropertiesPanelLayout { SelectionPropertyValue::ArrowStyle(_) => { self.arrow_style_buttons(left, control_top) } - _ => swatch_grid(left, control_top, swatches), + SelectionPropertyValue::Fill(_) => { + swatch_grid(left, control_top, swatches, SwatchExtra::NoFill) + } + _ => swatch_grid(left, control_top, swatches, SwatchExtra::MoreColors), }; return PropertiesRowGeometry { index, @@ -365,9 +370,15 @@ impl PropertiesPanelLayout { .find(|row| row.rect.contains(x, y))?; let index = row.index; let hit = match &row.control { - PropertiesRowControl::Swatches { swatches, more } => { - if more.contains(x, y) { + PropertiesRowControl::Swatches { + swatches, + none, + more, + } => { + if more.is_some_and(|more| more.contains(x, y)) { Some(PropertiesPanelHit::MoreColors(index)) + } else if none.is_some_and(|none| none.contains(x, y)) { + Some(PropertiesPanelHit::NoFill(index)) } else { swatches .iter() @@ -446,8 +457,9 @@ impl PropertiesPanelLayout { PropertiesRowControl::Swatches { swatches, .. }, ) => *swatches.get(index)?, (PropertiesPanelHit::MoreColors(_), PropertiesRowControl::Swatches { more, .. }) => { - more + more? } + (PropertiesPanelHit::NoFill(_), PropertiesRowControl::Swatches { none, .. }) => none?, (PropertiesPanelHit::Slider(_), PropertiesRowControl::Slider { track, .. }) => track, (PropertiesPanelHit::StepDown(_), PropertiesRowControl::Stepper { down, .. }) => down, (PropertiesPanelHit::StepUp(_), PropertiesRowControl::Stepper { up, .. }) => up, @@ -475,7 +487,16 @@ impl PropertiesPanelLayout { } } -fn swatch_grid(x: f64, top: f64, swatches: usize) -> PropertiesRowControl { +/// The extra cell a swatch grid carries beside its colors. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SwatchExtra { + /// A leading "no fill" cell, for the fill row. + NoFill, + /// A trailing "more colors" button, for the color row. + MoreColors, +} + +fn swatch_grid(x: f64, top: f64, swatches: usize, extra: SwatchExtra) -> PropertiesRowControl { let cell = |item: usize| { let line = item / SWATCH_ITEMS_PER_LINE; let column = item % SWATCH_ITEMS_PER_LINE; @@ -486,9 +507,17 @@ fn swatch_grid(x: f64, top: f64, swatches: usize) -> PropertiesRowControl { SWATCH_SIZE, ) }; - PropertiesRowControl::Swatches { - swatches: (0..swatches).map(cell).collect(), - more: cell(swatches), + match extra { + SwatchExtra::NoFill => PropertiesRowControl::Swatches { + swatches: (1..=swatches).map(cell).collect(), + none: Some(cell(0)), + more: None, + }, + SwatchExtra::MoreColors => PropertiesRowControl::Swatches { + swatches: (0..swatches).map(cell).collect(), + none: None, + more: Some(cell(swatches)), + }, } } @@ -507,7 +536,9 @@ impl ShapePropertiesPanel { /// The swatch holding the row's single color, which gets the selection /// ring. `None` for a mixed, locked, or custom color. pub fn current_swatch(&self, entry: &SelectionPropertyEntry) -> Option { - let SelectionPropertyValue::Color(Some(color)) = entry.state else { + let (SelectionPropertyValue::Color(Some(color)) + | SelectionPropertyValue::Fill(Some(Some(color)))) = entry.state + else { return None; }; palette_position(self.swatches.iter().map(|swatch| swatch.color), color) @@ -531,6 +562,7 @@ impl ShapePropertiesPanel { self.swatches.get(index).map(|swatch| swatch.label.clone()) } PropertiesPanelHit::MoreColors(_) => Some("More colors…".to_string()), + PropertiesPanelHit::NoFill(_) => Some("No fill".to_string()), PropertiesPanelHit::Action(action) => match action { PanelAction::ToBack => Some("Send to back".to_string()), PanelAction::Backward => Some("Send backward".to_string()), diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index 68159920..92675afb 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -108,7 +108,7 @@ fn measure_panel( .max(empty_width); for entry in &panel.entries { let row_width = match entry.state { - SelectionPropertyValue::Color(_) => { + SelectionPropertyValue::Color(_) | SelectionPropertyValue::Fill(_) => { label_width.max(swatch_grid_width(panel.swatches.len())) } SelectionPropertyValue::ArrowStyle(_) => { diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs index 5c45aa03..1c3dd377 100644 --- a/src/input/state/core/properties/panel_layout/tests.rs +++ b/src/input/state/core/properties/panel_layout/tests.rs @@ -84,7 +84,11 @@ fn inside(outer: PanelRect, inner: PanelRect) -> bool { fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelHit)> { let index = row.index; match &row.control { - PropertiesRowControl::Swatches { swatches, more } => swatches + PropertiesRowControl::Swatches { + swatches, + none, + more, + } => swatches .iter() .enumerate() .map(|(swatch, rect)| { @@ -96,7 +100,8 @@ fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelH }, ) }) - .chain([(*more, PropertiesPanelHit::MoreColors(index))]) + .chain(none.map(|rect| (rect, PropertiesPanelHit::NoFill(index)))) + .chain(more.map(|rect| (rect, PropertiesPanelHit::MoreColors(index)))) .collect(), PropertiesRowControl::Stepper { down, up, .. } => vec![ (*down, PropertiesPanelHit::StepDown(index)), @@ -178,6 +183,7 @@ fn every_control_is_hit_where_it_is_drawn_and_stays_in_its_row() { w: 40, h: 40, fill: false, + fill_color: None, color: PALETTE_RED, thick: 2.0, }); @@ -348,6 +354,7 @@ fn a_selection_taller_than_the_screen_flows_into_columns() { w: 40, h: 40, fill: false, + fill_color: None, color: PALETTE_RED, thick: 2.0, }); @@ -401,6 +408,7 @@ fn four_kinds(state: &mut InputState) -> Vec { w: 40, h: 40, fill: false, + fill_color: None, color: PALETTE_RED, thick: 2.0, }); diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs index 88e45b61..0511bfbe 100644 --- a/src/input/state/core/properties/summary.rs +++ b/src/input/state/core/properties/summary.rs @@ -106,11 +106,28 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option { } } -pub(super) fn shape_fill(shape: &Shape) -> Option { +/// What a closed shape is filled with: `None` for no fill, or the fill's +/// own color, which falls back to the border's. +pub(super) fn shape_fill_paint(shape: &Shape) -> Option> { match shape { - Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } | Shape::Polygon { fill, .. } => { - Some(*fill) + Shape::Rect { + fill, + fill_color, + color, + .. + } + | Shape::Ellipse { + fill, + fill_color, + color, + .. } + | Shape::Polygon { + fill, + fill_color, + color, + .. + } => Some(fill.then(|| fill_color.unwrap_or(*color))), _ => None, } } @@ -179,6 +196,7 @@ mod tests { w: 10, h: 10, fill, + fill_color: None, color, thick, } @@ -203,7 +221,7 @@ mod tests { wrap_width: None, }); - let summary = summarize_property(&frame, &[text_id], shape_fill, |a, b| a == b); + let summary = summarize_property(&frame, &[text_id], shape_fill_paint, |a, b| a == b); assert!(!summary.applicable); assert!(!summary.editable); diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 26977d57..94528f5c 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -78,7 +78,8 @@ impl SelectionPropertyKind { pub fn unknown_value(self) -> SelectionPropertyValue { match self { Self::Color => SelectionPropertyValue::Color(None), - Self::Fill | Self::TextBackground => SelectionPropertyValue::Toggle(None), + Self::Fill => SelectionPropertyValue::Fill(None), + Self::TextBackground => SelectionPropertyValue::Toggle(None), Self::ArrowHead => SelectionPropertyValue::ArrowHead(None), Self::ArrowStyle => SelectionPropertyValue::ArrowStyle(None), Self::Thickness | Self::Opacity => SelectionPropertyValue::Level(None), @@ -105,6 +106,8 @@ pub enum SelectionPropertyValue { /// A pressure stroke stores a width per point, so it has no one number. PressureVaries, Toggle(Option), + /// A closed shape's fill paint: `Some(None)` is no fill. + Fill(Option>), /// Whether the head sits at the end of the arrow (`true`) or its start. ArrowHead(Option), ArrowStyle(Option), @@ -190,6 +193,8 @@ pub enum PropertiesPanelHit { }, /// The swatch row's trailing button that opens the full color picker. MoreColors(usize), + /// The fill row's leading "no fill" swatch. + NoFill(usize), /// A slider's track, which follows the pointer while pressed. Slider(usize), StepDown(usize), @@ -213,6 +218,7 @@ impl PropertiesPanelHit { Self::Row(row) | Self::Swatch { row, .. } | Self::MoreColors(row) + | Self::NoFill(row) | Self::Slider(row) | Self::StepDown(row) | Self::StepUp(row) @@ -264,7 +270,10 @@ impl PanelRect { pub enum PropertiesRowControl { Swatches { swatches: Vec, - more: PanelRect, + /// The "no fill" swatch leading a fill row. + none: Option, + /// The "more colors" button trailing a color row. + more: Option, }, Stepper { down: PanelRect, diff --git a/src/input/state/core/recognition_chip.rs b/src/input/state/core/recognition_chip.rs index 8df4e803..2dc8d3ca 100644 --- a/src/input/state/core/recognition_chip.rs +++ b/src/input/state/core/recognition_chip.rs @@ -190,6 +190,7 @@ mod tests { rx, ry, fill: false, + fill_color: None, color: Color::new(1.0, 0.0, 0.0, 1.0), thick: 3.0, } diff --git a/src/input/state/core/selection/clipboard.rs b/src/input/state/core/selection/clipboard.rs index fdeef1c3..d5ce9449 100644 --- a/src/input/state/core/selection/clipboard.rs +++ b/src/input/state/core/selection/clipboard.rs @@ -408,6 +408,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color: WHITE, thick: 2.0, } diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index c06d9003..66790547 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -388,6 +388,7 @@ mod coach_tests { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/input/state/core/utility/restore_notice.rs b/src/input/state/core/utility/restore_notice.rs index 62bbcde1..1212824b 100644 --- a/src/input/state/core/utility/restore_notice.rs +++ b/src/input/state/core/utility/restore_notice.rs @@ -102,6 +102,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color, thick: 3.0, }); diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index 15653e4e..6729b993 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -466,6 +466,7 @@ mod tests { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index f17ba488..269e3070 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -52,6 +52,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs index d13f5c61..41b0a266 100644 --- a/src/input/state/mouse/press/polygon.rs +++ b/src/input/state/mouse/press/polygon.rs @@ -134,6 +134,7 @@ impl InputState { kind: PolygonKind::Freeform, points, fill, + fill_color: None, color, thick, }; diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index 5bb69806..475ecbf4 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -150,6 +150,7 @@ mod tests { w: 20, h: 20, fill: false, + fill_color: None, color: crate::draw::WHITE, thick: 2.0, }) diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index b2acbf2e..608bf8de 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -179,6 +179,7 @@ fn switch_board_cancels_selection_move_on_source_board_before_switching() { w: 30, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs index 274e31c0..66a98a66 100644 --- a/src/input/state/tests/delete_restore.rs +++ b/src/input/state/tests/delete_restore.rs @@ -272,6 +272,7 @@ fn page_delete_on_last_page_clears_shapes_without_removing_page() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -337,6 +338,7 @@ fn page_content_edit_does_not_stale_pending_page_delete() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index fcb0bb75..cfbdda1d 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -500,6 +500,7 @@ fn alt_click_selects_filled_polygon_interior() { kind: crate::draw::PolygonKind::Triangle, points: vec![(10, 10), (40, 10), (25, 40)], fill: true, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/erase.rs b/src/input/state/tests/erase.rs index b9deeac6..d3b7be26 100644 --- a/src/input/state/tests/erase.rs +++ b/src/input/state/tests/erase.rs @@ -169,6 +169,7 @@ fn erase_stroke_hits_various_shapes() { w: 40, h: 20, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -186,6 +187,7 @@ fn erase_stroke_hits_various_shapes() { rx: 20, ry: 10, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -335,6 +337,7 @@ fn spatial_grid_large_tolerance_finds_distant_shapes() { w: 10, h: 10, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/input/state/tests/hit_testing.rs b/src/input/state/tests/hit_testing.rs index 3a40bb05..7a6823f1 100644 --- a/src/input/state/tests/hit_testing.rs +++ b/src/input/state/tests/hit_testing.rs @@ -32,6 +32,7 @@ fn indexed_state_with_two_overlapping_rects() -> InputState { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -85,6 +86,7 @@ fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { w: i32::MIN, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -94,6 +96,7 @@ fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -122,6 +125,7 @@ fn spatial_hit_testing_reuses_resolved_candidate_indices_without_id_rescans() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -131,6 +135,7 @@ fn spatial_hit_testing_reuses_resolved_candidate_indices_without_id_rescans() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -201,6 +206,7 @@ fn spatial_hit_testing_rebuilds_after_public_shape_id_replacement() { w: 20, h: 20, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -222,6 +228,7 @@ fn spatial_hit_testing_rebuilds_after_public_shape_id_replacement() { w: 20, h: 20, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -253,6 +260,7 @@ fn spatial_shape_indices_follow_insert_delete_reorder_undo_and_redo() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }) diff --git a/src/input/state/tests/menus/clipboard.rs b/src/input/state/tests/menus/clipboard.rs index 324025fc..0483a068 100644 --- a/src/input/state/tests/menus/clipboard.rs +++ b/src/input/state/tests/menus/clipboard.rs @@ -7,6 +7,7 @@ fn add_rect(state: &mut InputState) -> crate::draw::ShapeId { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index cec4ff62..f915c409 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -52,6 +52,7 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -61,6 +62,7 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -103,6 +105,7 @@ fn shape_menu_includes_reset_canvas_position_on_solid_boards() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -133,6 +136,7 @@ fn select_this_shape_command_focuses_single_shape() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -142,6 +146,7 @@ fn select_this_shape_command_focuses_single_shape() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -174,6 +179,7 @@ fn properties_command_opens_panel() { w: 40, h: 30, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, @@ -412,6 +418,7 @@ fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -421,6 +428,7 @@ fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -689,6 +697,7 @@ fn keyboard_shape_menu_anchor_tracks_panned_board_view_offset() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -904,6 +913,7 @@ fn undo_and_redo_rows_follow_history_and_run_it() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); @@ -968,6 +978,7 @@ fn shape_menu_also_ends_with_exit() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); @@ -1048,6 +1059,7 @@ fn shape_menu_orders_by_steps_and_dims_the_way_the_shape_cannot_go() { w: 10, h: 10, fill: false, + fill_color: None, color: Color::new(1.0, 0.0, 0.0, 1.0), thick: 2.0, }; diff --git a/src/input/state/tests/menus/history.rs b/src/input/state/tests/menus/history.rs index cb08f911..1620b084 100644 --- a/src/input/state/tests/menus/history.rs +++ b/src/input/state/tests/menus/history.rs @@ -11,6 +11,7 @@ fn push_rect_create(state: &mut InputState, x: i32) { w: 10, h: 10, fill: false, + fill_color: None, color, thick, }); @@ -37,6 +38,7 @@ fn undo_all_and_redo_all_process_entire_stack() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -55,6 +57,7 @@ fn undo_all_and_redo_all_process_entire_stack() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/menus/locks.rs b/src/input/state/tests/menus/locks.rs index c9616896..35648346 100644 --- a/src/input/state/tests/menus/locks.rs +++ b/src/input/state/tests/menus/locks.rs @@ -43,6 +43,7 @@ fn shape_menu_disables_delete_when_all_locked() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -52,6 +53,7 @@ fn shape_menu_disables_delete_when_all_locked() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -88,6 +90,7 @@ fn shape_menu_allows_delete_when_mixed_lock_state() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -97,6 +100,7 @@ fn shape_menu_allows_delete_when_mixed_lock_state() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index ee66efd8..f2b08db6 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -10,6 +10,7 @@ fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> crate::dr w, h, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) @@ -255,9 +256,10 @@ fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { Shape::Rect { fill, .. } => assert!(*fill), other => panic!("expected rect, got {other:?}"), } + // With no fill color of its own, the fill takes the border's. assert_eq!( - state.properties_panel().expect("panel").entries[fill_index].value, - "On" + state.properties_panel().expect("panel").entries[fill_index].state, + SelectionPropertyValue::Fill(Some(Some(state.style.current_color))) ); } diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index 0a0de55b..4e7e285e 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -26,6 +26,7 @@ fn add_rect(state: &mut InputState, color: Color, fill: bool) -> ShapeId { w: 60, h: 40, fill, + fill_color: None, color, thick: 3.0, }) @@ -198,23 +199,79 @@ fn a_press_that_leaves_its_control_before_the_release_changes_nothing() { assert!(state.is_properties_panel_open()); } +fn fill_of(state: &InputState, id: ShapeId) -> (bool, Option) { + match &state.boards.active_frame().shape(id).expect("rect").shape { + Shape::Rect { + fill, fill_color, .. + } => (*fill, *fill_color), + other => panic!("expected rect, got {other:?}"), + } +} + +fn swatch_index(state: &InputState, color: Color) -> usize { + state + .properties_panel() + .unwrap() + .swatches + .iter() + .position(|swatch| swatch.color == color) + .expect("swatch") +} + #[test] -fn the_switch_and_the_rest_of_its_row_both_toggle_fill() { +fn a_fill_swatch_fills_with_its_own_color_and_no_fill_keeps_it_for_later() { + let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); let id = add_rect(&mut state, PALETTE_RED, false); open(&mut state, vec![id]); let fill = row(&state, "Fill"); + assert_eq!( + state.properties_panel().unwrap().entries[fill].value, + "None" + ); + let blue = swatch_index(&state, crate::domain::color::PALETTE_BLUE); - click(&mut state, PropertiesPanelHit::Toggle(fill)); - assert!(rect_of(&state, id).1); - assert_eq!(state.properties_panel().unwrap().entries[fill].value, "On"); + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: blue, + }, + ); + assert_eq!( + fill_of(&state, id), + (true, Some(crate::domain::color::PALETTE_BLUE)) + ); + assert_eq!( + rect_of(&state, id).0, + PALETTE_RED, + "the border keeps its color" + ); + let panel = state.properties_panel().unwrap(); + assert_eq!(panel.entries[fill].value, "Blue"); + assert_eq!(panel.current_swatch(&panel.entries[fill]), Some(blue)); + + click(&mut state, PropertiesPanelHit::NoFill(fill)); + assert_eq!( + fill_of(&state, id), + (false, Some(crate::domain::color::PALETTE_BLUE)), + "no fill keeps the color for when the fill comes back" + ); + assert_eq!( + state.properties_panel().unwrap().entries[fill].value, + "None" + ); - click(&mut state, PropertiesPanelHit::Row(fill)); - assert!(!rect_of(&state, id).1); + state.set_properties_panel_focus(Some(fill)); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Return)); + assert_eq!( + fill_of(&state, id), + (true, Some(crate::domain::color::PALETTE_BLUE)) + ); } #[test] -fn a_mixed_fill_selection_turns_on_from_the_switch() { +fn a_mixed_fill_selection_takes_one_fill_from_a_swatch() { let mut state = create_test_input_state(); let filled = add_rect(&mut state, PALETTE_RED, true); let outlined = add_rect(&mut state, PALETTE_RED, false); @@ -222,12 +279,58 @@ fn a_mixed_fill_selection_turns_on_from_the_switch() { let fill = row(&state, "Fill"); assert_eq!( state.properties_panel().unwrap().entries[fill].state, - SelectionPropertyValue::Toggle(None) + SelectionPropertyValue::Fill(None) + ); + let green = swatch_index(&state, PALETTE_GREEN); + + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: green, + }, ); - click(&mut state, PropertiesPanelHit::Toggle(fill)); + assert_eq!(fill_of(&state, filled), (true, Some(PALETTE_GREEN))); + assert_eq!(fill_of(&state, outlined), (true, Some(PALETTE_GREEN))); +} + +#[test] +fn a_fill_shares_the_shapes_opacity() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let half_red = Color { + a: 0.5, + ..PALETTE_RED + }; + let id = add_rect(&mut state, half_red, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + let green = swatch_index(&state, PALETTE_GREEN); + + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: green, + }, + ); + assert_eq!( + fill_of(&state, id).1, + Some(Color { + a: 0.5, + ..PALETTE_GREEN + }), + "an opaque swatch fills at the shape's opacity" + ); - assert!(rect_of(&state, filled).1 && rect_of(&state, outlined).1); + state.set_properties_panel_focus(Some(row(&state, "Opacity"))); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); + assert!((rect_of(&state, id).0.a - 0.55).abs() < 1e-9); + assert!( + (fill_of(&state, id).1.unwrap().a - 0.55).abs() < 1e-9, + "the fill follows the border's opacity" + ); } #[test] diff --git a/src/input/state/tests/selection/actions.rs b/src/input/state/tests/selection/actions.rs index 75d90daf..7e91193c 100644 --- a/src/input/state/tests/selection/actions.rs +++ b/src/input/state/tests/selection/actions.rs @@ -16,6 +16,7 @@ fn select_all_action_selects_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -25,6 +26,7 @@ fn select_all_action_selects_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -45,6 +47,7 @@ fn escape_clears_selection_before_exit() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -64,6 +67,7 @@ fn select_tool_drag_selects_shapes_in_rect() { w: 8, h: 8, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -73,6 +77,7 @@ fn select_tool_drag_selects_shapes_in_rect() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/damage.rs b/src/input/state/tests/selection/damage.rs index ba986a6f..1a186c7e 100644 --- a/src/input/state/tests/selection/damage.rs +++ b/src/input/state/tests/selection/damage.rs @@ -20,6 +20,7 @@ fn add_filled_rect(state: &mut InputState, x: i32, y: i32) -> crate::draw::Shape w: 40, h: 40, fill: true, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/tests/selection/deletion.rs b/src/input/state/tests/selection/deletion.rs index 5a60dfb4..f9c0d736 100644 --- a/src/input/state/tests/selection/deletion.rs +++ b/src/input/state/tests/selection/deletion.rs @@ -75,6 +75,7 @@ fn clear_all_skips_locked_shapes() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -84,6 +85,7 @@ fn clear_all_skips_locked_shapes() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -113,6 +115,7 @@ fn clear_all_returns_false_when_all_locked() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index 997d7ebe..dc059fa2 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -21,6 +21,7 @@ fn duplicate_selection_via_action_creates_offset_shape() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -65,6 +66,7 @@ fn copy_paste_selection_centers_shape_at_pointer() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -124,6 +126,7 @@ fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -167,6 +170,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -182,6 +186,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -220,6 +225,7 @@ fn failed_local_clipboard_precedence_clears_when_fingerprint_changes() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -288,6 +294,7 @@ fn failed_local_clipboard_without_failure_fingerprint_supersedes_when_current_is w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -332,6 +339,7 @@ fn failed_local_clipboard_without_current_fingerprint_does_not_fast_path() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -369,6 +377,7 @@ fn published_selection_allows_local_fallback_until_superseded() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -417,6 +426,7 @@ fn fallback_generation_rejects_newer_local_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -460,6 +470,7 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -479,6 +490,7 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -514,6 +526,7 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -535,6 +548,7 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -570,6 +584,7 @@ fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -616,6 +631,7 @@ fn request_generation_supersede_ignores_newer_local_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -635,6 +651,7 @@ fn request_generation_supersede_ignores_newer_local_copy() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -671,6 +688,7 @@ fn failed_local_fast_path_rejects_newer_generation() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -703,6 +721,7 @@ fn failed_local_fast_path_rejects_newer_generation() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -750,6 +769,7 @@ fn duplicate_selection_skips_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -759,6 +779,7 @@ fn duplicate_selection_skips_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -787,6 +808,7 @@ fn copy_selection_of_only_locked_shapes_leaves_clipboard_empty() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -815,6 +837,7 @@ fn repeated_paste_selection_uses_current_pointer_anchor() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -846,6 +869,7 @@ fn paste_selection_warns_when_shape_limit_prevents_any_paste() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -870,6 +894,7 @@ fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -879,6 +904,7 @@ fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/reorder.rs b/src/input/state/tests/selection/reorder.rs index 743a3aa4..f8222bbb 100644 --- a/src/input/state/tests/selection/reorder.rs +++ b/src/input/state/tests/selection/reorder.rs @@ -8,6 +8,7 @@ fn add(state: &mut InputState, x: i32) -> ShapeId { w: 10, h: 10, fill: false, + fill_color: None, color: Color::new(1.0, 0.0, 0.0, 1.0), thick: 2.0, }) diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index ba02d769..4699d734 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -2387,6 +2387,7 @@ fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 22f361c2..2b08296f 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -55,6 +55,7 @@ fn resizing_selection_marks_previous_live_bounds_dirty() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -114,6 +115,7 @@ fn resizing_selection_back_to_start_restores_original_geometry() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -184,6 +186,7 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -225,6 +228,7 @@ fn move_selection_to_horizontal_edges_ignores_last_axis() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -267,6 +271,7 @@ fn move_selection_to_vertical_edges_explicit_actions() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -307,6 +312,7 @@ fn nudge_selection_large_uses_large_step() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -339,6 +345,7 @@ fn nudge_selection_clamps_left_and_top_edges() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -370,6 +377,7 @@ fn nudge_selection_clamps_right_and_bottom_edges() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 5f344423..172365fc 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -243,6 +243,7 @@ impl Tool { w, h, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) @@ -260,6 +261,7 @@ impl Tool { rx, ry, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, } @@ -438,6 +440,7 @@ impl Tool { w, h, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) @@ -455,6 +458,7 @@ impl Tool { rx, ry, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) diff --git a/src/input/tool/drawing/polygon.rs b/src/input/tool/drawing/polygon.rs index 4d403b68..615f0a49 100644 --- a/src/input/tool/drawing/polygon.rs +++ b/src/input/tool/drawing/polygon.rs @@ -60,6 +60,7 @@ fn finish_polygon( kind: template.kind(snapshot.regular_sides), points, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }, @@ -81,6 +82,7 @@ fn provisional_polygon( kind: template.kind(snapshot.regular_sides), points, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index ffb067d9..815be5eb 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -377,6 +377,7 @@ fn fit_rectangle( w: bounds.width.round() as i32, h: bounds.height.round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/ellipse.rs b/src/input/tool/live_shape/ellipse.rs index a3e27c0f..f0175aee 100644 --- a/src/input/tool/live_shape/ellipse.rs +++ b/src/input/tool/live_shape/ellipse.rs @@ -85,6 +85,7 @@ pub(super) fn fit_ellipse( rx: rx.round() as i32, ry: ry.round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/grid.rs b/src/input/tool/live_shape/grid.rs index 83331cf3..eddbc02e 100644 --- a/src/input/tool/live_shape/grid.rs +++ b/src/input/tool/live_shape/grid.rs @@ -18,6 +18,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { w, h, fill, + fill_color, color, thick, }, @@ -30,6 +31,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { w, h, fill, + fill_color, color, thick, } @@ -42,6 +44,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { rx, ry, fill, + fill_color, color, thick, }, @@ -54,6 +57,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { rx: width / 2, ry: height / 2, fill, + fill_color, color, thick, } @@ -64,6 +68,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { kind, points, fill, + fill_color, color, thick, }, @@ -89,6 +94,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { kind, points, fill, + fill_color, color, thick, } diff --git a/src/input/tool/live_shape/rough_rectangle.rs b/src/input/tool/live_shape/rough_rectangle.rs index 538bbf50..b46ac9bd 100644 --- a/src/input/tool/live_shape/rough_rectangle.rs +++ b/src/input/tool/live_shape/rough_rectangle.rs @@ -74,6 +74,7 @@ pub(super) fn fit_rough_rectangle( w: (right - left).round() as i32, h: (bottom - top).round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/triangle.rs b/src/input/tool/live_shape/triangle.rs index 2f1def41..2ff0f837 100644 --- a/src/input/tool/live_shape/triangle.rs +++ b/src/input/tool/live_shape/triangle.rs @@ -53,6 +53,7 @@ pub(super) fn fit_triangle( .map(|(x, y)| (x.round() as i32, y.round() as i32)) .to_vec(), fill: false, + fill_color: None, color, thick, }, diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 35497bc0..abe8efcc 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -2231,6 +2231,7 @@ fn save_snapshot_preserves_multiple_pages() { w: 8, h: 8, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 6fa5b120..7268bf80 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -282,6 +282,7 @@ fn inspect_session_reports_counts_and_flags() { w: 10, h: 10, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/session/tests/history.rs b/src/session/tests/history.rs index 477f488e..cd5aa8d6 100644 --- a/src/session/tests/history.rs +++ b/src/session/tests/history.rs @@ -169,6 +169,7 @@ fn clear_all_can_be_undone_after_restore() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 1.0, diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index c43230e6..fca82133 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -144,6 +144,7 @@ fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/session/tests/roundtrip.rs b/src/session/tests/roundtrip.rs index 1d068e3b..baa45e6d 100644 --- a/src/session/tests/roundtrip.rs +++ b/src/session/tests/roundtrip.rs @@ -53,6 +53,7 @@ fn session_roundtrip_preserves_shapes_across_frames() { rx: 4, ry: 8, fill: false, + fill_color: None, color: Color { r: 1.0, g: 1.0, @@ -202,6 +203,7 @@ fn save_snapshot_skips_backup_when_disabled() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs index 7db5ed05..9b23a8dd 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs @@ -9,6 +9,7 @@ fn rect(x: i32, color: Color) -> Shape { w: 130, h: 95, fill: true, + fill_color: None, color, thick: 3.0, } diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 685da165..678a60c0 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -303,6 +303,7 @@ mod tests { w: 40, h: 20, fill: true, + fill_color: None, color: Color { r: 1.0, g: 1.0, diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs index 5d8955b1..fbbe241b 100644 --- a/src/ui/properties_panel.rs +++ b/src/ui/properties_panel.rs @@ -32,8 +32,8 @@ mod controls; use actions::draw_actions; use controls::{ - ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_slider, - draw_stepper, draw_swatches, draw_switch, + ControlState, SwatchExtras, draw_arrow_head_segments, draw_arrow_styles, draw_lock, + draw_slider, draw_stepper, draw_swatches, draw_switch, }; /// Wash behind the hovered row: a quieter `BG_HOVER`, so the control under @@ -237,10 +237,19 @@ fn draw_row( let state = ControlState { enabled, hover }; match &row.control { - PropertiesRowControl::Swatches { swatches, more } => { + PropertiesRowControl::Swatches { + swatches, + none, + more, + } => { draw_value_right(engine, ctx, row, entry, enabled); + let extras = SwatchExtras { + none: *none, + none_selected: entry.state == SelectionPropertyValue::Fill(Some(None)), + more: *more, + }; let current = panel.current_swatch(entry); - draw_swatches(ctx, &panel.swatches, swatches, *more, current, state); + draw_swatches(ctx, &panel.swatches, swatches, extras, current, state); } PropertiesRowControl::ArrowStyles { buttons } => { draw_value_right(engine, ctx, row, entry, enabled); diff --git a/src/ui/properties_panel/controls.rs b/src/ui/properties_panel/controls.rs index 3e40deba..dd0ffd0e 100644 --- a/src/ui/properties_panel/controls.rs +++ b/src/ui/properties_panel/controls.rs @@ -32,6 +32,8 @@ const SWATCH_HAIRLINE: Rgba = (1.0, 1.0, 1.0, 0.18); const QUIET_OUTLINE: Rgba = (1.0, 1.0, 1.0, 0.16); /// A locked selection's padlock: the same amber the toolbar uses for "held". const LOCK_ACTIVE: Rgba = (0.965, 0.827, 0.176, 1.0); +/// The stroke across the "no fill" swatch: the destructive red, as "none". +const NO_FILL_STRIKE: Rgba = crate::ui::theme::rgba(crate::ui::theme::DESTRUCTIVE_RGB, 0.9); /// How much of its color a control keeps while its row is locked. const DISABLED_ALPHA: f64 = 0.35; const GLYPH_WIDTH: f64 = 1.6; @@ -130,11 +132,29 @@ pub(super) fn draw_lock( let _ = ctx.stroke(); } +/// Where a swatch row's extra cells sit, and whether "no fill" is the +/// current choice. +#[derive(Clone, Copy)] +pub(super) struct SwatchExtras { + pub(super) none: Option, + pub(super) none_selected: bool, + pub(super) more: Option, +} + +fn selection_ring(ctx: &cairo::Context, rect: PanelRect, state: ControlState) { + let (cx, cy) = rect.center(); + ctx.new_path(); + ctx.arc(cx, cy, rect.width / 2.0 + 3.5, 0.0, TAU); + set_color(ctx, state.fade(ACCENT_BRIGHT)); + ctx.set_line_width(2.0); + let _ = ctx.stroke(); +} + pub(super) fn draw_swatches( ctx: &cairo::Context, swatches: &[PropertiesPanelSwatch], rects: &[PanelRect], - more: PanelRect, + extras: SwatchExtras, current: Option, state: ControlState, ) { @@ -163,14 +183,44 @@ pub(super) fn draw_swatches( let _ = ctx.stroke(); if current == Some(index) { - ctx.new_path(); - ctx.arc(cx, cy, rect.width / 2.0 + 3.5, 0.0, TAU); - set_color(ctx, state.fade(ACCENT_BRIGHT)); - ctx.set_line_width(2.0); - let _ = ctx.stroke(); + selection_ring(ctx, *rect, state); } } + if let Some(none) = extras.none { + draw_no_fill(ctx, none, extras.none_selected, state); + } + if let Some(more) = extras.more { + draw_more_colors(ctx, more, state); + } +} + +/// "No fill": an empty circle struck through, as a crossed-out swatch. +fn draw_no_fill(ctx: &cairo::Context, rect: PanelRect, selected: bool, state: ControlState) { + let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::NoFill(_))); + let (cx, cy) = rect.center(); + let radius = rect.width / 2.0 - 0.5; + ctx.new_path(); + ctx.arc(cx, cy, radius, 0.0, TAU); + set_color( + ctx, + state.fade(if hovered { + ACCENT_BRIGHT + } else { + QUIET_OUTLINE + }), + ); + ctx.set_line_width(1.5); + let _ = ctx.stroke(); + let reach = radius * std::f64::consts::FRAC_1_SQRT_2; + set_color(ctx, state.fade(NO_FILL_STRIKE)); + round_line(ctx, (cx - reach, cy + reach), (cx + reach, cy - reach)); + if selected { + selection_ring(ctx, rect, state); + } +} + +fn draw_more_colors(ctx: &cairo::Context, more: PanelRect, state: ControlState) { let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::MoreColors(_))); let (cx, cy) = more.center(); let outline = if hovered { diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index b26f6c90..3c1c5d67 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -79,6 +79,7 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/tests/ui.rs b/tests/ui.rs index 84b86410..321d9863 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -178,6 +178,7 @@ fn render_shape_ellipse_does_not_connect_to_existing_current_path() { rx: 20, ry: 10, fill: false, + fill_color: None, color: magenta, thick: 6.0, }, From 172c938b24f11f5fa5fdd93f707e01987c052f8e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:49:31 +0200 Subject: [PATCH 06/12] feat: apply and save tool presets from the properties panel The panel's actions area gains a preset row: one chip per preset slot, showing its color, and a Save button. A chip applies that preset's style to the selection as one undo entry: its color (a marker keeps its own translucency unless the preset sets one), its size as a stroke width, fill, font size, text background, and arrow head. The tool in use stays as it is; this restyles shapes rather than picking up the preset's tool. Save arms saving: the next chip clicked stores the style of the first editable selected shape a tool draws, with that tool, through the same path as the Save Preset actions, so it is written to the config. Escape disarms it before it closes the panel. Empty slots dim until saving is armed, and Save dims for a selection no tool draws, such as text. --- README.md | 2 +- src/input/state/actions/key_press/panels.rs | 4 +- src/input/state/core/properties/apply.rs | 26 ++- .../apply_selection/actions/color.rs | 8 +- .../properties/apply_selection/actions/mod.rs | 1 + .../apply_selection/actions/preset.rs | 207 ++++++++++++++++++ src/input/state/core/properties/metrics.rs | 8 +- src/input/state/core/properties/panel.rs | 16 +- .../core/properties/panel_layout/geometry.rs | 53 ++++- .../properties/panel_layout/interaction.rs | 17 +- .../core/properties/panel_layout/layout.rs | 1 + .../core/properties/panel_layout/tests.rs | 6 +- src/input/state/core/properties/state.rs | 3 + src/input/state/core/properties/types.rs | 63 ++++-- src/input/state/core/tool_controls/presets.rs | 6 + .../state/tests/properties_panel_controls.rs | 130 ++++++++++- src/ui/properties_panel/actions.rs | 107 +++++++-- 17 files changed, 605 insertions(+), 53 deletions(-) create mode 100644 src/input/state/core/properties/apply_selection/actions/preset.rs diff --git a/README.md b/README.md index c5939a6a..6c016ba0 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) -- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; scroll over a row to step it, and hover the title for the shape's ID and creation time +- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; a preset row applies a tool preset's style to the selection (keeping the current tool), and **Save** then a slot stores the selection's style as that preset; scroll over a row to step it, and hover the title for the shape's ID and creation time - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 4b5133f4..d4e5c098 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -420,7 +420,9 @@ impl InputState { }; match key { Key::Escape => { - self.close_properties_panel(); + if !self.cancel_properties_preset_save() { + self.close_properties_panel(); + } true } Key::Up => self.focus_previous_properties_entry(), diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 71ec3d82..71fc8622 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -169,7 +169,7 @@ impl InputState { .properties .panel .as_ref() - .is_some_and(|panel| panel.actions.enabled(action)); + .is_some_and(|panel| panel.action_enabled(action)); if !enabled { return false; } @@ -189,6 +189,30 @@ impl InputState { duplicated } PanelAction::Delete => self.delete_selection_with(measurer), + PanelAction::SavePreset => { + if let Some(panel) = self.properties.panel.as_mut() { + panel.preset_save_mode = !panel.preset_save_mode; + } + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + return true; + } + PanelAction::Preset(slot) => { + let saving = self + .properties + .panel + .as_ref() + .is_some_and(|panel| panel.preset_save_mode); + if saving { + if let Some(panel) = self.properties.panel.as_mut() { + panel.preset_save_mode = false; + } + self.selection_preset_source() + .is_some_and(|preset| self.store_preset(slot, preset)) + } else { + self.apply_preset_to_selection_with(measurer, slot) + } + } }; if changed && self.is_properties_panel_open() { diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 79187cf9..bc169e3d 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -21,7 +21,11 @@ pub(crate) enum RecolorOpacity { /// The color `shape` has now and the one a recolor to `target` gives it, or /// `None` for a shape without a color. A marker keeps its own opacity either /// way, which is what makes it a highlighter. -fn recolored(shape: &Shape, target: Color, opacity: RecolorOpacity) -> Option<(Color, Color)> { +pub(super) fn recolored( + shape: &Shape, + target: Color, + opacity: RecolorOpacity, +) -> Option<(Color, Color)> { let current = *shape_color_ref(shape)?; let keep_opacity = matches!(shape, Shape::MarkerStroke { .. }) || (opacity == RecolorOpacity::Swatch && target.a >= 1.0); @@ -53,7 +57,7 @@ fn shape_color_ref(shape: &Shape) -> Option<&Color> { } } -fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> { +pub(super) fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> { match shape { Shape::Freehand { color, .. } | Shape::FreehandPressure { color, .. } diff --git a/src/input/state/core/properties/apply_selection/actions/mod.rs b/src/input/state/core/properties/apply_selection/actions/mod.rs index 008d9487..69227e05 100644 --- a/src/input/state/core/properties/apply_selection/actions/mod.rs +++ b/src/input/state/core/properties/apply_selection/actions/mod.rs @@ -1,6 +1,7 @@ mod arrow; mod color; mod fill; +mod preset; mod spotlight; mod stroke; mod text; diff --git a/src/input/state/core/properties/apply_selection/actions/preset.rs b/src/input/state/core/properties/apply_selection/actions/preset.rs new file mode 100644 index 00000000..83f0c375 --- /dev/null +++ b/src/input/state/core/properties/apply_selection/actions/preset.rs @@ -0,0 +1,207 @@ +//! Tool presets and selected shapes: a preset's style applied to what is +//! selected, and a selected shape's style saved as a preset. + +use super::color::{RecolorOpacity, recolored, set_opacity_to, shape_color_mut}; +use super::stroke::set_thickness; +use crate::config::{ColorSpec, ToolPresetConfig}; +use crate::draw::{Color, PolygonKind, Shape, TextMeasurer}; +use crate::input::Tool; +use crate::input::state::core::base::{InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +use crate::input::state::core::properties::apply_selection::constants::{ + MAX_ARROW_ANGLE, MAX_ARROW_LENGTH, MAX_FONT_SIZE, MIN_ARROW_ANGLE, MIN_ARROW_LENGTH, + MIN_FONT_SIZE, +}; + +/// Applies the parts of `preset` that `shape` has: its color (a marker +/// keeping its own translucency unless the preset sets one), its size as a +/// stroke width, fill, font size, text background, and arrow head. +fn apply_preset_style(shape: &mut Shape, preset: &ToolPresetConfig) -> bool { + let mut changed = false; + + if let Some((current, next)) = recolored(shape, preset.preview_color(), RecolorOpacity::Exact) + && current != next + && let Some(color) = shape_color_mut(shape) + { + *color = next; + changed = true; + } + if !preset.tool.uses_eraser_size() { + let size = preset + .preview_size() + .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); + changed |= set_thickness(shape, size).unwrap_or(false); + } + if let (Shape::MarkerStroke { .. }, Some(opacity)) = (&*shape, preset.marker_opacity) { + changed |= set_opacity_to(shape, opacity).unwrap_or(false); + } + + match shape { + Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } | Shape::Polygon { fill, .. } => { + if let Some(enabled) = preset.fill_enabled { + changed |= replace(fill, enabled); + } + } + Shape::Text { + size, + background_enabled, + .. + } => { + if let Some(font_size) = preset.font_size { + changed |= replace(size, font_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)); + } + if let Some(enabled) = preset.text_background_enabled { + changed |= replace(background_enabled, enabled); + } + } + Shape::Arrow { + arrow_length, + arrow_angle, + head_at_end, + .. + } => { + if let Some(length) = preset.arrow_length { + changed |= replace( + arrow_length, + length.clamp(MIN_ARROW_LENGTH, MAX_ARROW_LENGTH), + ); + } + if let Some(angle) = preset.arrow_angle { + changed |= replace(arrow_angle, angle.clamp(MIN_ARROW_ANGLE, MAX_ARROW_ANGLE)); + } + if let Some(at_end) = preset.arrow_head_at_end { + changed |= replace(head_at_end, at_end); + } + } + _ => {} + } + changed +} + +fn replace(slot: &mut T, value: T) -> bool { + let changed = *slot != value; + *slot = value; + changed +} + +/// The preset a tool would need to draw `shape` as it is, or `None` for a +/// shape no tool draws with a color and a size. +fn preset_from_shape(shape: &Shape) -> Option { + let mut preset = ToolPresetConfig { + name: None, + tool: Tool::Pen, + color: ColorSpec::from(Color::new(0.0, 0.0, 0.0, 1.0)), + size: 1.0, + tool_settings: None, + eraser_kind: None, + eraser_mode: None, + marker_opacity: None, + fill_enabled: None, + font_size: None, + text_background_enabled: None, + arrow_length: None, + arrow_angle: None, + arrow_head_at_end: None, + polygon_sides: None, + show_status_bar: None, + drag_tools: None, + }; + let (tool, color, size) = match shape { + Shape::Freehand { color, thick, .. } => (Tool::Pen, *color, *thick), + Shape::Line { color, thick, .. } => (Tool::Line, *color, *thick), + Shape::Rect { + color, thick, fill, .. + } => { + preset.fill_enabled = Some(*fill); + (Tool::Rect, *color, *thick) + } + Shape::Ellipse { + color, thick, fill, .. + } => { + preset.fill_enabled = Some(*fill); + (Tool::Ellipse, *color, *thick) + } + Shape::Polygon { + kind, + color, + thick, + fill, + .. + } => { + preset.fill_enabled = Some(*fill); + let tool = match kind { + PolygonKind::Triangle => Tool::Triangle, + PolygonKind::Parallelogram => Tool::Parallelogram, + PolygonKind::Rhombus => Tool::Rhombus, + PolygonKind::Regular { sides } => { + preset.polygon_sides = Some(*sides); + Tool::RegularPolygon + } + PolygonKind::Freeform => Tool::FreeformPolygon, + }; + (tool, *color, *thick) + } + Shape::Arrow { + color, + thick, + arrow_length, + arrow_angle, + head_at_end, + .. + } => { + preset.arrow_length = Some(*arrow_length); + preset.arrow_angle = Some(*arrow_angle); + preset.arrow_head_at_end = Some(*head_at_end); + (Tool::Arrow, *color, *thick) + } + Shape::MarkerStroke { color, thick, .. } => { + preset.marker_opacity = Some(color.a); + (Tool::Marker, Color { a: 1.0, ..*color }, *thick) + } + _ => return None, + }; + preset.tool = tool; + preset.color = ColorSpec::from(color); + preset.size = size; + Some(preset) +} + +impl InputState { + /// Styles every editable selected shape after preset `slot`, as one undo + /// entry. The tool in use stays as it is: this restyles shapes, it does + /// not pick up the preset's tool. + pub(in crate::input::state::core::properties) fn apply_preset_to_selection_with( + &mut self, + measurer: &TextMeasurer, + slot: usize, + ) -> bool { + let Some(preset) = self.preset_slots.preset(slot) else { + return false; + }; + + self.finish_active_arrow_bend(); + let result = self.apply_selection_change_with(measurer, shape_supports_presets, |shape| { + apply_preset_style(shape, &preset) + }); + + self.report_selection_apply_result(result, "preset") + } + + /// The preset for the first editable selected shape a tool can draw. + pub(in crate::input::state::core::properties) fn selection_preset_source( + &self, + ) -> Option { + let frame = self.boards.active_frame(); + self.selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .filter(|drawn| !drawn.locked) + .find_map(|drawn| preset_from_shape(&drawn.shape)) + } +} + +fn shape_supports_presets(shape: &Shape) -> bool { + !matches!( + shape, + Shape::Image { .. } | Shape::EraserStroke { .. } | Shape::Spotlight { .. } + ) +} diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs index 0b7556b5..e75abfcb 100644 --- a/src/input/state/core/properties/metrics.rs +++ b/src/input/state/core/properties/metrics.rs @@ -79,15 +79,17 @@ pub(crate) const LOCK_SIZE: f64 = 26.0; /// The lock button's inset from the panel's top-right corner. pub(crate) const LOCK_INSET: f64 = 8.0; -/// The actions area: a divider, the ordering row, and the Duplicate/Delete -/// row under it. +/// The actions area: a divider, the ordering row, the Duplicate/Delete row, +/// and the preset row under them. pub(crate) const ACTIONS_TOP_GAP: f64 = 12.0; pub(crate) const ACTION_BUTTON_HEIGHT: f64 = 28.0; pub(crate) const ACTION_ROW_GAP: f64 = 8.0; pub(crate) const ACTION_BUTTON_GAP: f64 = 6.0; pub(crate) const ACTIONS_BOTTOM: f64 = 6.0; pub(crate) const ACTIONS_HEIGHT: f64 = - ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 2.0 + ACTION_ROW_GAP + ACTIONS_BOTTOM; + ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 3.0 + ACTION_ROW_GAP * 2.0 + ACTIONS_BOTTOM; +/// The preset row's Save button, at the row's end. +pub(crate) const PRESET_SAVE_WIDTH: f64 = 56.0; /// The "Order" label's column, where both rows of buttons start. pub(crate) const ACTIONS_LABEL_WIDTH: f64 = 60.0; diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 817c3f55..b1c4954f 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -2,7 +2,7 @@ use super::super::base::InputState; use super::metrics::MAX_SWATCHES; use super::panel_layout::selection_panel_anchor; use super::types::{ - PanelActions, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, + PanelActions, PanelPreset, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, SelectionPropertyEntry, SelectionPropertyValue, ShapePropertiesPanel, }; use super::utils::format_timestamp; @@ -112,6 +112,7 @@ impl InputState { keyboard_focus: None, focus_visible: false, scroll: 0.0, + preset_save_mode: false, multiple_selection: contents.multiple_selection, }); true @@ -254,6 +255,19 @@ impl InputState { can_raise: self.selection_can_step(true), can_lower: self.selection_can_step(false), can_edit: lock != PropertiesPanelLock::Locked, + can_save_preset: self.selection_preset_source().is_some(), + presets: self + .preset_slots + .presets() + .iter() + .take(self.preset_slots.slot_count()) + .map(|preset| { + preset.as_ref().map(|preset| PanelPreset { + color: preset.preview_color(), + name: preset.name.clone(), + }) + }) + .collect(), }; Some(PanelContents { diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index 01aeea93..3dc945b5 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -4,11 +4,11 @@ use super::super::metrics::{ ACTION_BUTTON_GAP, ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_LABEL_WIDTH, ACTIONS_TOP_GAP, - BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING, ROW_HEIGHT, - ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, SLIDER_HIT_HEIGHT, SLIDER_THUMB_RADIUS, - SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP, STYLE_BUTTON_HEIGHT, - SWATCH_GAP, SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, - TITLE_FONT, + BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING, + PRESET_SAVE_WIDTH, ROW_HEIGHT, ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, SLIDER_HIT_HEIGHT, + SLIDER_THUMB_RADIUS, SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP, + STYLE_BUTTON_HEIGHT, SWATCH_GAP, SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE, + SWITCH_HEIGHT, SWITCH_WIDTH, TITLE_FONT, }; use super::super::types::{ PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, @@ -149,9 +149,9 @@ impl PropertiesPanelLayout { .is_none_or(|scroll| y >= self.rows_top && y < scroll.viewport_bottom) } - /// The actions area's buttons: the four ordering buttons in one row and - /// Duplicate and Delete under them, both starting after the "Order" - /// label's column and spanning the full content width. + /// The actions area's buttons: the four ordering buttons, Duplicate and + /// Delete under them, and the preset slots with Save at the end, every + /// row starting after the label column and spanning the content width. pub fn action_buttons(&self) -> Vec<(PanelAction, PanelRect)> { let left = self.content_x() + ACTIONS_LABEL_WIDTH; let width = self.content_right() - left; @@ -174,6 +174,24 @@ impl PropertiesPanelLayout { let edit_top = order_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; let mut buttons = row(order_top, &PanelAction::ORDER); buttons.extend(row(edit_top, &PanelAction::EDIT)); + + let preset_top = edit_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; + let save = PanelRect::new( + self.content_right() - PRESET_SAVE_WIDTH, + preset_top, + PRESET_SAVE_WIDTH, + ACTION_BUTTON_HEIGHT, + ); + let slots = self.preset_slots.max(1) as f64; + let chip = (save.x - ACTION_BUTTON_GAP - left - ACTION_BUTTON_GAP * (slots - 1.0)) / slots; + buttons.extend((0..self.preset_slots).map(|index| { + let x = left + index as f64 * (chip + ACTION_BUTTON_GAP); + ( + PanelAction::Preset(index + 1), + PanelRect::new(x, preset_top, chip, ACTION_BUTTON_HEIGHT), + ) + })); + buttons.push((PanelAction::SavePreset, save)); buttons } @@ -569,6 +587,25 @@ impl ShapePropertiesPanel { PanelAction::Forward => Some("Bring forward".to_string()), PanelAction::ToFront => Some("Bring to front".to_string()), PanelAction::Duplicate | PanelAction::Delete => None, + PanelAction::SavePreset => Some( + if self.preset_save_mode { + "Pick a slot to save into" + } else { + "Save the selection's style as a preset" + } + .to_string(), + ), + PanelAction::Preset(slot) => Some(if self.preset_save_mode { + format!("Save to preset {slot}") + } else { + match self.actions.presets.get(slot - 1) { + Some(Some(preset)) => match &preset.name { + Some(name) => format!("Apply preset {slot}: {name}"), + None => format!("Apply preset {slot}"), + }, + _ => format!("Preset {slot} is empty"), + } + }), }, _ => None, } diff --git a/src/input/state/core/properties/panel_layout/interaction.rs b/src/input/state/core/properties/panel_layout/interaction.rs index c6264316..d9b041a5 100644 --- a/src/input/state/core/properties/panel_layout/interaction.rs +++ b/src/input/state/core/properties/panel_layout/interaction.rs @@ -23,7 +23,7 @@ impl InputState { let hit = self.properties_panel_hit_at(x, y)?; let panel = self.properties.panel.as_ref()?; if let PropertiesPanelHit::Action(action) = hit - && !panel.actions.enabled(action) + && !panel.action_enabled(action) { return None; } @@ -202,4 +202,19 @@ impl InputState { self.needs_redraw = true; true } + + /// Disarms saving into a preset slot. Returns false when it was not + /// armed, so Escape can fall through to closing the panel. + pub(crate) fn cancel_properties_preset_save(&mut self) -> bool { + let Some(panel) = self.properties.panel.as_mut() else { + return false; + }; + if !panel.preset_save_mode { + return false; + } + panel.preset_save_mode = false; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } } diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index 92675afb..e69c79a0 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -263,6 +263,7 @@ impl InputState { divider_y: origin_y + divider, rows_top: origin_y + rows_top, actions_top: origin_y + rows_top + fit.height, + preset_slots: panel.actions.presets.len(), footer_top: has_footer.then_some(origin_y + rows_top + fit.height + ACTIONS_HEIGHT), stepper_value_width: measured.stepper_value_width, head_segment_width: measured.head_segment_width, diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs index 1c3dd377..08665a57 100644 --- a/src/input/state/core/properties/panel_layout/tests.rs +++ b/src/input/state/core/properties/panel_layout/tests.rs @@ -373,7 +373,9 @@ fn a_selection_taller_than_the_screen_flows_into_columns() { "one column on a tall screen" ); - lay_out(&mut state, (SCREEN.0, 480)); + // Wide enough for as many columns as the rows need at this height. + let wide = 1400; + lay_out(&mut state, (wide, 480)); let layout = *state.properties_panel_layout().expect("layout"); let rows = rows(&state); @@ -381,7 +383,7 @@ fn a_selection_taller_than_the_screen_flows_into_columns() { rows.iter().map(|row| row.content_x.to_bits()).collect(); assert!(columns.len() > 1, "the rows spread over columns"); assert!(layout.width > single.width); - assert!(layout.origin_x + layout.width <= SCREEN.0 as f64 - 12.0 + 1e-9); + assert!(layout.origin_x + layout.width <= f64::from(wide) - 12.0 + 1e-9); assert_all_reachable(&state, 480.0); let heights: Vec = columns .iter() diff --git a/src/input/state/core/properties/state.rs b/src/input/state/core/properties/state.rs index 7f459bc9..278ffdd2 100644 --- a/src/input/state/core/properties/state.rs +++ b/src/input/state/core/properties/state.rs @@ -80,6 +80,8 @@ mod tests { can_raise: false, can_lower: false, can_edit: false, + can_save_preset: false, + presets: Vec::new(), }, preview_color: None, hover: None, @@ -87,6 +89,7 @@ mod tests { keyboard_focus: None, focus_visible: false, scroll: 0.0, + preset_save_mode: false, multiple_selection: false, } } diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 94528f5c..bf6970e3 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -147,6 +147,11 @@ pub enum PanelAction { ToFront, Duplicate, Delete, + /// A preset slot, numbered from 1: applies it, or saves into it while + /// saving is armed. + Preset(usize), + /// Arms saving the selection's style into the next preset slot clicked. + SavePreset, } impl PanelAction { @@ -156,26 +161,28 @@ impl PanelAction { pub const EDIT: [Self; 2] = [Self::Duplicate, Self::Delete]; } +/// One tool preset slot, as the panel's preset chips show it. +#[derive(Debug, Clone, PartialEq)] +pub struct PanelPreset { + pub color: Color, + pub name: Option, +} + /// Which actions can do anything for the current selection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct PanelActions { /// Some selected shape has an unselected one above it. pub can_raise: bool, /// Some selected shape has an unselected one below it. pub can_lower: bool, - /// Some selected shape is unlocked, so duplicating or deleting has - /// something to work on. + /// Some selected shape is unlocked, so duplicating, deleting, or applying + /// a preset has something to work on. pub can_edit: bool, -} - -impl PanelActions { - pub fn enabled(&self, action: PanelAction) -> bool { - match action { - PanelAction::ToBack | PanelAction::Backward => self.can_lower, - PanelAction::Forward | PanelAction::ToFront => self.can_raise, - PanelAction::Duplicate | PanelAction::Delete => self.can_edit, - } - } + /// Some editable selected shape is one a tool draws, so its style can be + /// saved as a preset. + pub can_save_preset: bool, + /// The preset slots, in order; `None` for an empty one. + pub presets: Vec>, } /// The part of the properties panel under the pointer. @@ -335,8 +342,11 @@ pub struct PropertiesPanelLayout { pub lock: PanelRect, pub divider_y: f64, pub rows_top: f64, - /// Top of the actions area (ordering, Duplicate, Delete), under the rows. + /// Top of the actions area (ordering, Duplicate, Delete, presets), under + /// the rows. pub actions_top: f64, + /// How many preset slots the preset row shows. + pub preset_slots: usize, /// Top of the keyboard hint strip; `None` when the panel has no rows. pub footer_top: Option, /// Width of the readout between a stepper's − and + buttons. @@ -385,10 +395,35 @@ pub struct ShapePropertiesPanel { pub focus_visible: bool, /// How far the rows are scrolled, when they overflow a short screen. pub scroll: f64, + /// Whether the next preset chip clicked saves into that slot rather than + /// applying it. + pub preset_save_mode: bool, pub multiple_selection: bool, } impl ShapePropertiesPanel { + /// Whether the actions-area button `action` would do anything now. + pub fn action_enabled(&self, action: PanelAction) -> bool { + let actions = &self.actions; + match action { + PanelAction::ToBack | PanelAction::Backward => actions.can_lower, + PanelAction::Forward | PanelAction::ToFront => actions.can_raise, + PanelAction::Duplicate | PanelAction::Delete => actions.can_edit, + PanelAction::SavePreset => actions.can_save_preset, + PanelAction::Preset(slot) => { + if self.preset_save_mode { + actions.can_save_preset + } else { + actions.can_edit + && actions + .presets + .get(slot.wrapping_sub(1)) + .is_some_and(Option::is_some) + } + } + } + } + /// The row under the pointer, if any. pub fn hover_index(&self) -> Option { self.hover.and_then(PropertiesPanelHit::row) diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index 1fcaf165..3c26e31d 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -114,6 +114,12 @@ impl InputState { pub fn save_preset(&mut self, slot: usize) -> bool { let preset = self.capture_current_preset(); + self.store_preset(slot, preset) + } + + /// Stores `preset` in `slot` and hands it to the backend to persist. + /// Returns false when the slot already holds exactly this preset. + pub(crate) fn store_preset(&mut self, slot: usize, preset: ToolPresetConfig) -> bool { let Some(preset) = self.preset_slots.save(slot, preset) else { return false; }; diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index 4e7e285e..bc8e7af5 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -789,8 +789,8 @@ fn order_buttons_move_the_shape_and_dim_at_the_ends_of_the_stack() { assert_eq!(stack_order(&state), vec![middle, top, bottom]); let panel = state.properties_panel().expect("panel stays open"); - assert!(!panel.actions.enabled(PanelAction::ToFront)); - assert!(panel.actions.enabled(PanelAction::Backward)); + assert!(!panel.action_enabled(PanelAction::ToFront)); + assert!(panel.action_enabled(PanelAction::Backward)); assert!( panel .subtitle @@ -839,10 +839,10 @@ fn a_locked_selection_can_be_reordered_but_not_duplicated_or_deleted() { state.boards.active_frame_mut().shapes[index].locked = true; open(&mut state, vec![locked]); - let actions = state.properties_panel().unwrap().actions; - assert!(!actions.enabled(PanelAction::Duplicate)); - assert!(!actions.enabled(PanelAction::Delete)); - assert!(actions.enabled(PanelAction::Forward)); + let panel = state.properties_panel().unwrap(); + assert!(!panel.action_enabled(PanelAction::Duplicate)); + assert!(!panel.action_enabled(PanelAction::Delete)); + assert!(panel.action_enabled(PanelAction::Forward)); let delete = point(&state, PropertiesPanelHit::Action(PanelAction::Delete)); click_at(&mut state, delete, delete); assert_eq!(state.boards.active_frame().shapes.len(), 2); @@ -994,3 +994,121 @@ fn the_picker_sets_opacity_exactly() { assert_eq!(rect_of(&state, id).0, half_green); } + +fn add_styled_rect(state: &mut InputState, color: Color, thick: f64, fill: bool) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 100, + y: 100, + w: 60, + h: 40, + fill, + fill_color: None, + color, + thick, + }) +} + +#[test] +fn a_selection_saves_its_style_into_a_preset_slot_and_another_takes_it_on() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let empty_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("an empty slot") + + 1; + let source = add_styled_rect(&mut state, PALETTE_GREEN, 8.0, true); + open(&mut state, vec![source]); + let slot = PropertiesPanelHit::Action(PanelAction::Preset(empty_slot)); + let (x, y) = point(&state, slot); + assert_eq!( + state.properties_panel_active_hit_at(x, y), + None, + "an empty slot has nothing to apply" + ); + + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + assert!(state.properties_panel().unwrap().preset_save_mode); + assert_eq!( + state.properties_panel().unwrap().tooltip(slot), + Some(format!("Save to preset {empty_slot}")) + ); + click(&mut state, slot); + + assert!(!state.properties_panel().unwrap().preset_save_mode); + let saved = state.preset_slots.preset(empty_slot).expect("saved preset"); + assert_eq!(saved.tool, Tool::Rect); + assert_eq!(saved.preview_color(), PALETTE_GREEN); + assert_eq!(saved.size, 8.0); + assert_eq!(saved.fill_enabled, Some(true)); + assert!(matches!( + state.take_pending_preset_action(), + Some(crate::input::state::PresetAction::Save { slot, .. }) if slot == empty_slot + )); + + let tool_before = state.active_tool(); + let target = add_styled_rect(&mut state, PALETTE_RED, 3.0, false); + open(&mut state, vec![target]); + let depth = undo_depth(&state); + click(&mut state, slot); + + assert_eq!(rect_of(&state, target), (PALETTE_GREEN, true, 8.0)); + assert_eq!( + undo_depth(&state), + depth + 1, + "one undo entry for the whole style" + ); + assert_eq!(state.active_tool(), tool_before, "the tool stays as it was"); +} + +#[test] +fn escape_disarms_saving_before_it_closes_the_panel() { + use crate::input::state::PanelAction; + + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Escape)); + assert!(state.is_properties_panel_open()); + assert!(!state.properties_panel().unwrap().preset_save_mode); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Escape)); + assert!(!state.is_properties_panel_open()); +} + +#[test] +fn a_shape_no_tool_draws_cannot_be_saved_as_a_preset() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let note = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 100, + y: 100, + text: "Note".into(), + color: PALETTE_RED, + size: 18.0, + font_descriptor: Default::default(), + background_enabled: false, + wrap_width: None, + }); + open(&mut state, vec![note]); + + assert!( + !state + .properties_panel() + .unwrap() + .action_enabled(PanelAction::SavePreset) + ); +} diff --git a/src/ui/properties_panel/actions.rs b/src/ui/properties_panel/actions.rs index 1a53b69a..26763b3a 100644 --- a/src/ui/properties_panel/actions.rs +++ b/src/ui/properties_panel/actions.rs @@ -3,15 +3,17 @@ use cairo::FontWeight; +use crate::draw::Color; use crate::input::state::properties_panel_metrics::{ - ACTION_BUTTON_HEIGHT, ACTIONS_TOP_GAP, BODY_FONT, VALUE_FONT, text_style, + ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_TOP_GAP, BODY_FONT, VALUE_FONT, text_style, }; use crate::input::state::{ PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, ShapePropertiesPanel, }; use crate::ui::primitives::draw_rounded_rect; use crate::ui::theme::overlay::{ - BG_HOVER, BG_HOVER_WASH, DIVIDER_LIGHT, RADIUS_STD, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, + ACCENT_BRIGHT, ACCENT_PRIMARY, BG_HOVER, BG_HOVER_WASH, DIVIDER_LIGHT, RADIUS_STD, + TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, }; use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba, set_color, with_alpha}; use crate::ui_text::UiTextEngine; @@ -34,37 +36,116 @@ pub(super) fn draw_actions( let _ = ctx.stroke(); set_color(ctx, TEXT_PRIMARY); - engine.draw_baseline( - ctx, - text_style(BODY_FONT, FontWeight::Normal), - "Order", - layout.content_x(), - layout.actions_top + ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT / 2.0 + BODY_FONT * 0.35, - None, - ); + let label_style = text_style(BODY_FONT, FontWeight::Normal); + let order_top = layout.actions_top + ACTIONS_TOP_GAP; + let preset_top = order_top + (ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP) * 2.0; + for (label, top) in [("Order", order_top), ("Preset", preset_top)] { + engine.draw_baseline( + ctx, + label_style, + label, + layout.content_x(), + top + ACTION_BUTTON_HEIGHT / 2.0 + BODY_FONT * 0.35, + None, + ); + } for (action, rect) in layout.action_buttons() { - let enabled = panel.actions.enabled(action); + let enabled = panel.action_enabled(action); let hovered = enabled && panel.hover == Some(PropertiesPanelHit::Action(action)); - set_color(ctx, if hovered { BG_HOVER } else { BG_HOVER_WASH }); + let armed = action == PanelAction::SavePreset && panel.preset_save_mode; + set_color( + ctx, + if armed { + ACCENT_PRIMARY + } else if hovered { + BG_HOVER + } else { + BG_HOVER_WASH + }, + ); draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD); let _ = ctx.fill(); + if panel.preset_save_mode && matches!(action, PanelAction::Preset(_)) { + // Saving is armed: the slots are what to click next. + set_color(ctx, with_alpha(ACCENT_BRIGHT, 0.7)); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + rect.x + 0.5, + rect.y + 0.5, + rect.width - 1.0, + rect.height - 1.0, + RADIUS_STD, + ); + let _ = ctx.stroke(); + } let ink = match (enabled, action) { (false, _) => TEXT_DISABLED, (true, PanelAction::Delete) => DELETE_TEXT, - (true, _) if hovered => TEXT_PRIMARY, + (true, _) if hovered || armed => TEXT_PRIMARY, (true, _) => TEXT_SECONDARY, }; set_color(ctx, ink); match action { PanelAction::Duplicate => draw_label(engine, ctx, rect, "Duplicate"), PanelAction::Delete => draw_label(engine, ctx, rect, "Delete"), + PanelAction::SavePreset => draw_label(engine, ctx, rect, "Save"), + PanelAction::Preset(slot) => { + let preset = panel.actions.presets.get(slot - 1).and_then(Option::as_ref); + draw_preset_chip(engine, ctx, rect, slot, preset.map(|p| p.color), ink); + } order => draw_order_glyph(ctx, rect, order), } } } +/// A preset slot: its number beside a dot of the preset's color, or an +/// empty ring for a slot with nothing saved. +fn draw_preset_chip( + engine: &UiTextEngine, + ctx: &cairo::Context, + rect: PanelRect, + slot: usize, + color: Option, + ink: Rgba, +) { + let style = text_style(VALUE_FONT, FontWeight::Normal); + let number = slot.to_string(); + let number_width = engine + .layout(ctx, style, &number, None) + .ink_extents() + .x_advance(); + let dot = 4.5; + let gap = 4.0; + let (cx, cy) = rect.center(); + let left = cx - (dot * 2.0 + gap + number_width) / 2.0; + + ctx.new_path(); + ctx.arc(left + dot, cy, dot, 0.0, std::f64::consts::TAU); + match color { + Some(color) => { + set_color(ctx, (color.r, color.g, color.b, color.a.max(0.35))); + let _ = ctx.fill(); + } + None => { + set_color(ctx, ink); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + } + } + set_color(ctx, ink); + engine.draw_baseline( + ctx, + style, + &number, + left + dot * 2.0 + gap, + cy + VALUE_FONT * 0.35, + None, + ); +} + fn draw_label(engine: &UiTextEngine, ctx: &cairo::Context, rect: PanelRect, label: &str) { let style = text_style(VALUE_FONT, FontWeight::Normal); let width = engine From 7de047a39d3d23c5558db7a7903f0042d9bfa67d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:04:54 +0200 Subject: [PATCH 07/12] fix: check selection ordering in one pass Whether the selection can step forward or back scanned the stack from every selected shape and looked each neighbour up in the selection list, so with a whole board selected it grew with the cube of the shape count: seconds on a board of thousands. The properties panel asks twice on every refresh, slider drags included. It now holds exactly when the lowest selected shape sits below the highest unselected one (or, backward, the highest selected above the lowest unselected), which one pass with a hash set finds. The step itself uses a hash set for membership too. --- .../state/core/selection_actions/reorder.rs | 45 ++++++++++++----- src/input/state/tests/selection/reorder.rs | 50 +++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/input/state/core/selection_actions/reorder.rs b/src/input/state/core/selection_actions/reorder.rs index c6bee03a..5e37ed77 100644 --- a/src/input/state/core/selection_actions/reorder.rs +++ b/src/input/state/core/selection_actions/reorder.rs @@ -1,6 +1,8 @@ use super::super::base::InputState; +use crate::draw::ShapeId; use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; +use std::collections::HashSet; impl InputState { pub(crate) fn move_selection_to_front_with(&mut self, measurer: &TextMeasurer) -> bool { @@ -26,22 +28,41 @@ impl InputState { /// Whether some selected shape has an unselected shape above it (or below /// it, for `forward == false`), so a step that way would move something. + /// + /// That holds exactly when the lowest selected shape sits below the + /// highest unselected one (or, backward, the highest selected above the + /// lowest unselected), so one pass over the stack answers it. The panel + /// asks on every refresh, slider drags included, so it has to stay linear + /// on a board of thousands of shapes. pub(crate) fn selection_can_step(&self, forward: bool) -> bool { - let selected = self.selected_shape_ids(); - let shapes = &self.boards.active_frame().shapes; - let is_selected = |index: usize| selected.contains(&shapes[index].id); - (0..shapes.len()).any(|index| { - is_selected(index) - && if forward { - (index + 1..shapes.len()).any(|above| !is_selected(above)) - } else { - (0..index).any(|below| !is_selected(below)) - } - }) + let selected: HashSet = self.selected_shape_ids().iter().copied().collect(); + if selected.is_empty() { + return false; + } + + let mut lowest_selected = None; + let mut highest_selected = None; + let mut lowest_unselected = None; + let mut highest_unselected = None; + for (index, shape) in self.boards.active_frame().shapes.iter().enumerate() { + let (lowest, highest) = if selected.contains(&shape.id) { + (&mut lowest_selected, &mut highest_selected) + } else { + (&mut lowest_unselected, &mut highest_unselected) + }; + lowest.get_or_insert(index); + *highest = Some(index); + } + + if forward { + matches!((lowest_selected, highest_unselected), (Some(s), Some(u)) if s < u) + } else { + matches!((highest_selected, lowest_unselected), (Some(s), Some(u)) if s > u) + } } fn step_selection(&mut self, measurer: &TextMeasurer, forward: bool) -> bool { - let selected: Vec<_> = self.selected_shape_ids().to_vec(); + let selected: HashSet = self.selected_shape_ids().iter().copied().collect(); if selected.is_empty() { return false; } diff --git a/src/input/state/tests/selection/reorder.rs b/src/input/state/tests/selection/reorder.rs index f8222bbb..2a718cb5 100644 --- a/src/input/state/tests/selection/reorder.rs +++ b/src/input/state/tests/selection/reorder.rs @@ -109,3 +109,53 @@ fn a_step_is_one_undo_entry_that_undoes_and_redoes() { state.handle_action_with_resources(resources, Action::Redo); assert_eq!(order(&state), after); } + +/// The definition the fast check has to match: some selected shape has an +/// unselected one above (forward) or below (backward) it. +fn can_step_by_definition(state: &InputState, forward: bool) -> bool { + let selected = state.selected_shape_ids(); + let shapes = &state.boards.active_frame().shapes; + let is_selected = |index: usize| selected.contains(&shapes[index].id); + (0..shapes.len()).any(|index| { + is_selected(index) + && if forward { + (index + 1..shapes.len()).any(|above| !is_selected(above)) + } else { + (0..index).any(|below| !is_selected(below)) + } + }) +} + +#[test] +fn step_availability_matches_its_definition_for_every_selection() { + let (mut state, ids) = stack(6); + for mask in 0u32..(1 << ids.len()) { + let selection: Vec = ids + .iter() + .enumerate() + .filter(|(bit, _)| mask & (1 << bit) != 0) + .map(|(_, id)| *id) + .collect(); + state.set_selection(selection); + for forward in [true, false] { + assert_eq!( + state.selection_can_step(forward), + can_step_by_definition(&state, forward), + "mask {mask:06b}, forward {forward}" + ); + } + } +} + +#[test] +fn a_whole_board_selected_has_nowhere_to_step() { + let (mut state, ids) = stack(4_000); + state.set_selection(ids.clone()); + + assert!(!state.selection_can_step(true)); + assert!(!state.selection_can_step(false)); + + state.set_selection(ids[1..].to_vec()); + assert!(!state.selection_can_step(true)); + assert!(state.selection_can_step(false)); +} From bc499ffa6d86d331c7eb705519d486c69fd5753f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:06:33 +0200 Subject: [PATCH 08/12] fix: land a slider drag before any other edit records history A slider drag records nothing until its release, then commits one entry measured from snapshots taken when it began. A keyboard step pressed mid-drag recorded its own entry first, so the drag's landed on top of it with a stale "before", and two undos from 3 to 50 to 49 ended at 50 instead of 3. The drag now commits first whenever another edit would record history: any selection edit, a panel key, a panel click, or an undo or redo. --- src/input/state/actions/key_press/panels.rs | 3 + src/input/state/core/history.rs | 3 + src/input/state/core/properties/apply.rs | 2 + .../properties/apply_selection/helpers.rs | 5 ++ .../state/tests/properties_panel_controls.rs | 82 +++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index d4e5c098..78ef65ae 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -413,6 +413,9 @@ impl InputState { measurer: &crate::draw::TextMeasurer, key: Key, ) -> bool { + // A key pressed mid-drag ends the drag first, so what it changes is + // its own undo entry after the drag's rather than under it. + self.finish_properties_slider_drag_with(measurer); let adjust_step = if self.modifiers.shift { PROPERTIES_PANEL_COARSE_STEP } else { diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 9e2938a7..ff3f96cf 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -28,6 +28,9 @@ impl InputState { measurer: &TextMeasurer, step: impl FnOnce(&mut Frame) -> Option, ) -> bool { + // A live slider drag holds snapshots from before it began; it has to + // land as its own entry before history moves past it. + self.finish_properties_slider_drag_with(measurer); let selection_chrome = self.selection_chrome_bounds_with(measurer); let Some(action) = step(self.boards.active_frame_mut()) else { return false; diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 71fc8622..5ec4adc2 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -15,6 +15,8 @@ impl InputState { measurer: &TextMeasurer, hit: PropertiesPanelHit, ) -> bool { + // Locking, ordering, presets: none may record history under a drag. + self.finish_properties_slider_drag_with(measurer); if hit == PropertiesPanelHit::Lock { return self.toggle_properties_panel_lock_with(measurer); } diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index 134200eb..8e48904b 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -65,6 +65,11 @@ impl InputState { A: FnMut(&Shape) -> bool, F: FnMut(&mut Shape) -> bool, { + // Every selection edit comes through here. A slider drag still in + // progress holds snapshots from before it began, so it commits first: + // committing after this edit's entry would record its "before" on top + // of it, and undoing would walk back through a state never shown. + self.finish_properties_slider_drag_with(measurer); let ids = self.selected_shape_ids().to_vec(); let (changed, locked, applicable, effects) = crate::input::state::core::editing::CanvasEdit::apply_selection( diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index bc8e7af5..1be4310d 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -1112,3 +1112,85 @@ fn a_shape_no_tool_draws_cannot_be_saved_as_a_preset() { .action_enabled(PanelAction::SavePreset) ); } + +#[test] +fn a_key_pressed_mid_drag_lands_after_the_drag_in_history() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + let (x, y) = (track.right() as i32 - 1, track.center().1 as i32); + + assert!(state.handle_properties_panel_press_with_measurer(&measurer, MouseButton::Left, x, y)); + assert_eq!(rect_of(&state, id).2, 50.0); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Left)); + assert!( + !state.is_properties_slider_dragging(), + "the key ended the drag" + ); + assert_eq!(rect_of(&state, id).2, 49.0); + state.release_properties_panel_at_with(&measurer, x, y); + assert_eq!( + rect_of(&state, id).2, + 49.0, + "the release does not jump back" + ); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(rect_of(&state, id).2, 50.0, "the key's step undoes first"); + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!( + rect_of(&state, id).2, + 3.0, + "then the drag, back to the start" + ); +} + +#[test] +fn undo_mid_drag_first_lands_the_drag() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.right() as i32 - 1, + track.center().1 as i32 + )); + + state.handle_action_with_resources(resources, Action::Undo); + + assert!(!state.is_properties_slider_dragging()); + assert_eq!(rect_of(&state, id).2, 3.0, "the drag landed, then undid"); + state.handle_action_with_resources(resources, Action::Redo); + assert_eq!(rect_of(&state, id).2, 50.0); +} From f38cb71a7eaee3d5089120582bcffde1a1e69174 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:22:11 +0200 Subject: [PATCH 09/12] fix: keep a preset's own fill color when saving and applying it A preset saved from a rectangle, ellipse, or polygon filled with its own color kept only whether it was filled and the border color, so applying it filled the target with the border's color: a red rectangle with a green fill came back filled red. Presets gain an optional fill_color, written only when set. Saving from the panel captures it, applying restores it on selected closed shapes (a preset without one resets a separate fill to follow the border), and the configurator carries it through when it rewrites a slot. --- config.example.toml | 3 + configurator/src/models/config/presets.rs | 8 ++- configurator/src/models/config/tests.rs | 3 + docs/CONFIG.md | 5 +- src/backend/wayland/config_edits/tests.rs | 1 + .../wayland/state/toolbar/events/presets.rs | 1 + src/config/tests/validate.rs | 1 + src/config/tests/write_lock.rs | 1 + src/config/tests/write_target.rs | 28 ++++++++ src/config/types/presets.rs | 7 ++ src/input/state/core/presets.rs | 1 + .../apply_selection/actions/preset.rs | 32 ++++++++- src/input/state/core/style.rs | 1 + src/input/state/tests/basics.rs | 2 + src/input/state/tests/focus_mode.rs | 1 + src/input/state/tests/presenter_mode.rs | 1 + .../state/tests/properties_panel_controls.rs | 67 +++++++++++++++++++ src/input/state/tests/tool_controls.rs | 6 ++ 18 files changed, 164 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index 2672bcf0..a9303818 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1356,6 +1356,9 @@ size = 3.0 # Optional overrides marker_opacity = 0.32 fill_enabled = false +# Optional fill color, when a fill differs from `color` (written when a +# preset is saved from a shape filled with its own color) +# fill_color = "blue" font_size = 32.0 text_background_enabled = false arrow_length = 20.0 diff --git a/configurator/src/models/config/presets.rs b/configurator/src/models/config/presets.rs index c4638f8f..ca165e00 100644 --- a/configurator/src/models/config/presets.rs +++ b/configurator/src/models/config/presets.rs @@ -6,7 +6,7 @@ use super::super::fields::{ use super::super::util::format_float; use super::parse::{parse_optional_f64, parse_required_f64}; use wayscriber::config::{ - Config, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, + ColorSpec, Config, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig, PresetToolStatesConfig, ToolPresetConfig, }; use wayscriber::domain::Tool; @@ -22,6 +22,9 @@ pub struct PresetSlotDraft { pub eraser_mode: PresetEraserModeOption, pub marker_opacity: String, pub fill_enabled: OverrideOption, + /// Carried through unedited: set when a preset is saved from a shape + /// filled with its own color, so editing the slot here keeps it. + pub fill_color: Option, pub font_size: String, pub text_background_enabled: OverrideOption, pub arrow_length: String, @@ -46,6 +49,7 @@ impl PresetSlotDraft { eraser_mode: PresetEraserModeOption::from_option(preset.eraser_mode), marker_opacity: preset.marker_opacity.map(format_float).unwrap_or_default(), fill_enabled: OverrideOption::from_option(preset.fill_enabled), + fill_color: preset.fill_color.clone(), font_size: preset.font_size.map(format_float).unwrap_or_default(), text_background_enabled: OverrideOption::from_option( preset.text_background_enabled, @@ -77,6 +81,7 @@ impl PresetSlotDraft { eraser_mode: PresetEraserModeOption::Default, marker_opacity: String::new(), fill_enabled: OverrideOption::Default, + fill_color: None, font_size: String::new(), text_background_enabled: OverrideOption::Default, arrow_length: String::new(), @@ -174,6 +179,7 @@ impl PresetSlotDraft { eraser_mode: self.eraser_mode.to_option(), marker_opacity, fill_enabled: self.fill_enabled.to_option(), + fill_color: self.fill_color.clone(), font_size, text_background_enabled: self.text_background_enabled.to_option(), arrow_length, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 596c3199..74e9d7b2 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1398,6 +1398,7 @@ fn config_draft_round_trips_presets_and_history() { eraser_mode: None, marker_opacity: Some(0.5), fill_enabled: Some(true), + fill_color: Some(ColorSpec::Name("green".to_string())), font_size: Some(14.0), text_background_enabled: Some(false), arrow_length: Some(20.0), @@ -1467,6 +1468,7 @@ fn preset_tool_change_loads_selected_tool_profile_values() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -1525,6 +1527,7 @@ fn preset_visible_edits_update_selected_tool_profile_only() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 568a095b..05b0809a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -762,12 +762,15 @@ size = 28.0 ``` **Required fields:** `tool`, `color`, `size` -**Optional fields:** `tool_settings`, `eraser_kind`, `eraser_mode`, `marker_opacity`, `fill_enabled`, `font_size`, `text_background_enabled`, `arrow_length`, `arrow_angle`, `arrow_head_at_end`, `polygon_sides`, `show_status_bar`, `drag_tools` +**Optional fields:** `tool_settings`, `eraser_kind`, `eraser_mode`, `marker_opacity`, `fill_enabled`, `fill_color`, `font_size`, `text_background_enabled`, `arrow_length`, `arrow_angle`, `arrow_head_at_end`, `polygon_sides`, `show_status_bar`, `drag_tools` When `tool_settings` is present, applying the preset restores the full drawing profile for all tools, including StepMarker size and Eraser size, then activates `tool`. Legacy presets without `tool_settings` keep the old behavior and apply only `color`/`size` to the selected `tool`. The top-level `color` and `size` are retained for compatibility, readability, and toolbar previews. +`fill_color` is written when a preset is saved from the properties panel off a rectangle, ellipse, +or polygon filled with its own color; applying the preset to selected closed shapes restores that +fill. Without it, a preset's fill takes its border color. ### `[history]` - Undo/Redo Playback diff --git a/src/backend/wayland/config_edits/tests.rs b/src/backend/wayland/config_edits/tests.rs index 70b14e34..abc942d6 100644 --- a/src/backend/wayland/config_edits/tests.rs +++ b/src/backend/wayland/config_edits/tests.rs @@ -39,6 +39,7 @@ fn preset(name: &str) -> Box { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/backend/wayland/state/toolbar/events/presets.rs b/src/backend/wayland/state/toolbar/events/presets.rs index 366591a7..cf360896 100644 --- a/src/backend/wayland/state/toolbar/events/presets.rs +++ b/src/backend/wayland/state/toolbar/events/presets.rs @@ -155,6 +155,7 @@ mod tests { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 3d08da54..844b1663 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -526,6 +526,7 @@ fn validate_clamps_preset_fields() { eraser_mode: None, marker_opacity: Some(1.2), fill_enabled: None, + fill_color: None, font_size: Some(2.0), text_background_enabled: None, arrow_length: Some(100.0), diff --git a/src/config/tests/write_lock.rs b/src/config/tests/write_lock.rs index 2eeb6609..6784fa5e 100644 --- a/src/config/tests/write_lock.rs +++ b/src/config/tests/write_lock.rs @@ -50,6 +50,7 @@ fn preset(name: &str) -> ToolPresetConfig { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/config/tests/write_target.rs b/src/config/tests/write_target.rs index 1c19ff37..b75696cd 100644 --- a/src/config/tests/write_target.rs +++ b/src/config/tests/write_target.rs @@ -61,6 +61,7 @@ fn preset(name: &str) -> ToolPresetConfig { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -834,3 +835,30 @@ fn an_edit_that_waits_for_the_lock_reapplies_onto_the_retargeted_file() { "and the file the path used to name keeps every byte" ); } + +/// A preset saved from a shape filled with its own color keeps that fill +/// through the file, and one without leaves no trace of the field. +#[test] +fn a_preset_keeps_its_fill_color_through_the_config_file() { + let temp = crate::test_temp::tempdir().expect("tempdir"); + let path = temp.path().join("config.toml"); + fs::write(&path, ORIGINAL).expect("seed config"); + let mut filled = preset("Filled"); + filled.tool = crate::input::Tool::Rect; + filled.fill_enabled = Some(true); + filled.fill_color = Some(ColorSpec::from(Color { + r: 0.0, + g: 0.8, + b: 0.4, + a: 1.0, + })); + + persist_preset_slot_at(&path, 1, Some(&filled)).expect("write filled preset"); + persist_preset_slot_at(&path, 2, Some(&preset("Plain"))).expect("write plain preset"); + + let written = fs::read_to_string(&path).expect("readable"); + assert_eq!(written.matches("fill_color").count(), 1, "{written}"); + let loaded: super::super::Config = toml::from_str(&written).expect("parse"); + assert_eq!(loaded.presets.get_slot(1), Some(&filled)); + assert_eq!(loaded.presets.get_slot(2), Some(&preset("Plain"))); +} diff --git a/src/config/types/presets.rs b/src/config/types/presets.rs index cff91baf..7c8bd08e 100644 --- a/src/config/types/presets.rs +++ b/src/config/types/presets.rs @@ -46,6 +46,13 @@ pub struct ToolPresetConfig { #[serde(default)] pub fill_enabled: Option, + /// Optional fill color, for a fill that differs from `color`. Set when a + /// preset is saved from a shape filled with its own color; applying the + /// preset to selected closed shapes restores it. Absent, a fill takes the + /// border color. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_color: Option, + /// Optional font size override. #[serde(default)] pub font_size: Option, diff --git a/src/input/state/core/presets.rs b/src/input/state/core/presets.rs index 4e592419..f26972bf 100644 --- a/src/input/state/core/presets.rs +++ b/src/input/state/core/presets.rs @@ -162,6 +162,7 @@ mod tests { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/core/properties/apply_selection/actions/preset.rs b/src/input/state/core/properties/apply_selection/actions/preset.rs index 83f0c375..f96286c8 100644 --- a/src/input/state/core/properties/apply_selection/actions/preset.rs +++ b/src/input/state/core/properties/apply_selection/actions/preset.rs @@ -36,9 +36,22 @@ fn apply_preset_style(shape: &mut Shape, preset: &ToolPresetConfig) -> bool { } match shape { - Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } | Shape::Polygon { fill, .. } => { + Shape::Rect { + fill, fill_color, .. + } + | Shape::Ellipse { + fill, fill_color, .. + } + | Shape::Polygon { + fill, fill_color, .. + } => { + // A preset with a fill setting carries the whole fill: its own + // color, or none, which fills with the border color the preset + // just set. if let Some(enabled) = preset.fill_enabled { changed |= replace(fill, enabled); + let preset_fill = preset.fill_color.as_ref().map(ColorSpec::to_color); + changed |= replace(fill_color, preset_fill); } } Shape::Text { @@ -96,6 +109,7 @@ fn preset_from_shape(shape: &Shape) -> Option { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -109,15 +123,25 @@ fn preset_from_shape(shape: &Shape) -> Option { Shape::Freehand { color, thick, .. } => (Tool::Pen, *color, *thick), Shape::Line { color, thick, .. } => (Tool::Line, *color, *thick), Shape::Rect { - color, thick, fill, .. + color, + thick, + fill, + fill_color, + .. } => { preset.fill_enabled = Some(*fill); + preset.fill_color = fill_color.map(ColorSpec::from); (Tool::Rect, *color, *thick) } Shape::Ellipse { - color, thick, fill, .. + color, + thick, + fill, + fill_color, + .. } => { preset.fill_enabled = Some(*fill); + preset.fill_color = fill_color.map(ColorSpec::from); (Tool::Ellipse, *color, *thick) } Shape::Polygon { @@ -125,9 +149,11 @@ fn preset_from_shape(shape: &Shape) -> Option { color, thick, fill, + fill_color, .. } => { preset.fill_enabled = Some(*fill); + preset.fill_color = fill_color.map(ColorSpec::from); let tool = match kind { PolygonKind::Triangle => Tool::Triangle, PolygonKind::Parallelogram => Tool::Parallelogram, diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index ad5e5956..28a48517 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -483,6 +483,7 @@ impl DrawingStyle { eraser_mode: Some(self.eraser_mode), marker_opacity: Some(self.marker_opacity), fill_enabled: Some(self.fill_enabled), + fill_color: None, font_size: Some(self.current_font_size), text_background_enabled: Some(self.text_background_enabled), arrow_length: Some(self.arrow_length), diff --git a/src/input/state/tests/basics.rs b/src/input/state/tests/basics.rs index 7859bc95..fe23b5c4 100644 --- a/src/input/state/tests/basics.rs +++ b/src/input/state/tests/basics.rs @@ -88,6 +88,7 @@ fn apply_preset_updates_tool_and_settings() { eraser_mode: Some(EraserMode::Stroke), marker_opacity: Some(0.6), fill_enabled: Some(true), + fill_color: None, font_size: Some(28.0), text_background_enabled: Some(true), arrow_length: Some(25.0), @@ -140,6 +141,7 @@ fn apply_preset_merges_partial_left_drag_tool_bindings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index c052c97c..8fd8fc27 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -200,6 +200,7 @@ fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index b74beb9d..043ffef7 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -85,6 +85,7 @@ fn presenter_mode_blocks_preset_status_bar_toggle() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index 4900d56d..e0ed7caa 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -1213,3 +1213,70 @@ fn undo_mid_drag_first_lands_the_drag() { state.handle_action_with_resources(resources, Action::Redo); assert_eq!(rect_of(&state, id).2, 50.0); } + +#[test] +fn a_preset_saved_from_a_separately_filled_shape_brings_its_fill_along() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let empty_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("an empty slot") + + 1; + let source = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 100, + y: 100, + w: 60, + h: 40, + fill: true, + fill_color: Some(PALETTE_GREEN), + color: PALETTE_RED, + thick: 3.0, + }); + open(&mut state, vec![source]); + let slot = PropertiesPanelHit::Action(PanelAction::Preset(empty_slot)); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + click(&mut state, slot); + + let target = add_styled_rect(&mut state, crate::domain::color::PALETTE_BLUE, 5.0, true); + open(&mut state, vec![target]); + click(&mut state, slot); + + assert_eq!(rect_of(&state, target).0, PALETTE_RED, "the border"); + assert_eq!( + fill_of(&state, target), + (true, Some(PALETTE_GREEN)), + "the fill" + ); + + // A preset whose fill follows its border resets a separate fill. + let plain_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("another empty slot") + + 1; + let plain = add_styled_rect(&mut state, PALETTE_RED, 3.0, true); + open(&mut state, vec![plain]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Preset(plain_slot)), + ); + open(&mut state, vec![target]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Preset(plain_slot)), + ); + assert_eq!(fill_of(&state, target), (true, None)); +} diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 4699d734..9a8a7fac 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -1934,6 +1934,7 @@ fn apply_full_preset_restores_all_tool_settings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -1984,6 +1985,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2003,6 +2005,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2050,6 +2053,7 @@ fn legacy_preset_changes_only_selected_tool_settings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2088,6 +2092,7 @@ fn legacy_step_marker_preset_uses_font_derived_size() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: Some(48.0), text_background_enabled: None, arrow_length: None, @@ -2125,6 +2130,7 @@ fn full_step_marker_preset_uses_captured_profile_size() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: Some(48.0), text_background_enabled: None, arrow_length: None, From 537912dadb48ddf5da30407eb52df500e0a40466 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:31:12 +0200 Subject: [PATCH 10/12] fix: give the preset slots room of their own The five preset chips and Save shared the button column after the label, leaving each chip about 38 px for a color dot and a number, which touched its edges. The presets now take two lines: a label line with a wider Save at its end, then the slot chips across the panel's full width, with more space between dot and number. --- src/input/state/core/properties/metrics.rs | 9 ++--- .../core/properties/panel_layout/geometry.rs | 34 +++++++++++-------- .../core/properties/panel_layout/tests.rs | 2 +- src/ui/properties_panel/actions.rs | 6 ++-- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs index e75abfcb..30d9aedf 100644 --- a/src/input/state/core/properties/metrics.rs +++ b/src/input/state/core/properties/metrics.rs @@ -80,16 +80,17 @@ pub(crate) const LOCK_SIZE: f64 = 26.0; pub(crate) const LOCK_INSET: f64 = 8.0; /// The actions area: a divider, the ordering row, the Duplicate/Delete row, -/// and the preset row under them. +/// and the presets: a label line with Save at its end, then the slot chips +/// across the full width. pub(crate) const ACTIONS_TOP_GAP: f64 = 12.0; pub(crate) const ACTION_BUTTON_HEIGHT: f64 = 28.0; pub(crate) const ACTION_ROW_GAP: f64 = 8.0; pub(crate) const ACTION_BUTTON_GAP: f64 = 6.0; pub(crate) const ACTIONS_BOTTOM: f64 = 6.0; pub(crate) const ACTIONS_HEIGHT: f64 = - ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 3.0 + ACTION_ROW_GAP * 2.0 + ACTIONS_BOTTOM; -/// The preset row's Save button, at the row's end. -pub(crate) const PRESET_SAVE_WIDTH: f64 = 56.0; + ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 4.0 + ACTION_ROW_GAP * 3.0 + ACTIONS_BOTTOM; +/// The Save button at the end of the presets' label line. +pub(crate) const PRESET_SAVE_WIDTH: f64 = 72.0; /// The "Order" label's column, where both rows of buttons start. pub(crate) const ACTIONS_LABEL_WIDTH: f64 = 60.0; diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index 3dc945b5..edc99272 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -149,9 +149,11 @@ impl PropertiesPanelLayout { .is_none_or(|scroll| y >= self.rows_top && y < scroll.viewport_bottom) } - /// The actions area's buttons: the four ordering buttons, Duplicate and - /// Delete under them, and the preset slots with Save at the end, every - /// row starting after the label column and spanning the content width. + /// The actions area's buttons: the four ordering buttons, and Duplicate + /// and Delete under them, both after the label column; then Save at the + /// end of the presets' label line, and the preset slots under it across + /// the full content width, which gives each chip room for its dot and + /// number. pub fn action_buttons(&self) -> Vec<(PanelAction, PanelRect)> { let left = self.content_x() + ACTIONS_LABEL_WIDTH; let width = self.content_right() - left; @@ -175,23 +177,27 @@ impl PropertiesPanelLayout { let mut buttons = row(order_top, &PanelAction::ORDER); buttons.extend(row(edit_top, &PanelAction::EDIT)); - let preset_top = edit_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; - let save = PanelRect::new( - self.content_right() - PRESET_SAVE_WIDTH, - preset_top, - PRESET_SAVE_WIDTH, - ACTION_BUTTON_HEIGHT, - ); + let preset_label_top = edit_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; + let chips_top = preset_label_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP; let slots = self.preset_slots.max(1) as f64; - let chip = (save.x - ACTION_BUTTON_GAP - left - ACTION_BUTTON_GAP * (slots - 1.0)) / slots; + let chip = + (self.content_right() - self.content_x() - ACTION_BUTTON_GAP * (slots - 1.0)) / slots; buttons.extend((0..self.preset_slots).map(|index| { - let x = left + index as f64 * (chip + ACTION_BUTTON_GAP); + let x = self.content_x() + index as f64 * (chip + ACTION_BUTTON_GAP); ( PanelAction::Preset(index + 1), - PanelRect::new(x, preset_top, chip, ACTION_BUTTON_HEIGHT), + PanelRect::new(x, chips_top, chip, ACTION_BUTTON_HEIGHT), ) })); - buttons.push((PanelAction::SavePreset, save)); + buttons.push(( + PanelAction::SavePreset, + PanelRect::new( + self.content_right() - PRESET_SAVE_WIDTH, + preset_label_top, + PRESET_SAVE_WIDTH, + ACTION_BUTTON_HEIGHT, + ), + )); buttons } diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs index 08665a57..4f74f570 100644 --- a/src/input/state/core/properties/panel_layout/tests.rs +++ b/src/input/state/core/properties/panel_layout/tests.rs @@ -7,7 +7,7 @@ use crate::draw::{ArrowStyle, Shape, ShapeId, TextMeasurer}; use crate::input::state::InputState; use crate::ui_text::UiTextEngine; -const SCREEN: (u32, u32) = (800, 600); +const SCREEN: (u32, u32) = (800, 900); fn arrow(state: &mut InputState) -> ShapeId { state.boards.active_frame_mut().add_shape(Shape::Arrow { diff --git a/src/ui/properties_panel/actions.rs b/src/ui/properties_panel/actions.rs index 26763b3a..89beee42 100644 --- a/src/ui/properties_panel/actions.rs +++ b/src/ui/properties_panel/actions.rs @@ -39,7 +39,7 @@ pub(super) fn draw_actions( let label_style = text_style(BODY_FONT, FontWeight::Normal); let order_top = layout.actions_top + ACTIONS_TOP_GAP; let preset_top = order_top + (ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP) * 2.0; - for (label, top) in [("Order", order_top), ("Preset", preset_top)] { + for (label, top) in [("Order", order_top), ("Presets", preset_top)] { engine.draw_baseline( ctx, label_style, @@ -117,8 +117,8 @@ fn draw_preset_chip( .layout(ctx, style, &number, None) .ink_extents() .x_advance(); - let dot = 4.5; - let gap = 4.0; + let dot = 5.0; + let gap = 6.0; let (cx, cy) = rect.center(); let left = cx - (dot * 2.0 + gap + number_width) / 2.0; From 4c9b1683bb4349f68bbe1e331fa4a48d25f9b5f6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:42:00 +0200 Subject: [PATCH 11/12] feat: pick any fill color from the properties panel The Fill row gets a + that opens the color picker for the fill alone. OK fills the selection with exactly that color, opacity included, as one undo entry; the border keeps its color. --- .../state/core/color_picker_popup/mod.rs | 6 +- .../state/core/color_picker_popup/state.rs | 33 ++++++++- src/input/state/core/properties/apply.rs | 11 ++- .../apply_selection/actions/fill.rs | 56 ++++++++++++-- .../core/properties/panel_layout/geometry.rs | 73 ++++++++++--------- .../core/properties/panel_layout/layout.rs | 6 +- .../state/tests/properties_panel_controls.rs | 73 +++++++++++++++++++ 7 files changed, 207 insertions(+), 51 deletions(-) diff --git a/src/input/state/core/color_picker_popup/mod.rs b/src/input/state/core/color_picker_popup/mod.rs index 0ed626b9..a7775bfa 100644 --- a/src/input/state/core/color_picker_popup/mod.rs +++ b/src/input/state/core/color_picker_popup/mod.rs @@ -100,6 +100,9 @@ pub enum ColorPickerTarget { /// previewed live: OK recolors the selection in one undoable edit and /// Cancel leaves it untouched. Selection, + /// The selected closed shapes' fill, opened from the properties panel's + /// fill row. Like `Selection`, OK fills them in one undoable edit. + SelectionFill, } impl ColorPickerTarget { @@ -109,7 +112,8 @@ impl ColorPickerTarget { ColorPickerTarget::QuickColor(index) => Some(index), ColorPickerTarget::Tool | ColorPickerTarget::BoardPaper - | ColorPickerTarget::Selection => None, + | ColorPickerTarget::Selection + | ColorPickerTarget::SelectionFill => None, } } diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index b48dd62e..2c42fddc 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -109,6 +109,21 @@ impl InputState { true } + /// Opens the popup on the selection's fill, from the properties panel's + /// fill row. Returns false when nothing selected can be filled. + pub(crate) fn open_color_picker_popup_for_selection_fill_with_measurer( + &mut self, + measurer: &TextMeasurer, + ) -> bool { + let Some(color) = self.selection_fill_paint_source() else { + return false; + }; + self.discard_open_color_picker_recolor(); + self.clear_properties_panel_pointer_state(); + self.open_color_picker_popup_for(measurer, ColorPickerTarget::SelectionFill, color); + true + } + /// Whether the open popup edits the paper sheet's color draft. pub fn color_picker_popup_edits_board_paper(&self) -> bool { self.color_picker_popup.target() == Some(ColorPickerTarget::BoardPaper) @@ -180,7 +195,10 @@ impl InputState { ModalSurface::ColorPicker, ModalSurface::BoardPicker, ); - } else if target == ColorPickerTarget::Selection { + } else if matches!( + target, + ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill + ) { // Opened from the properties panel, which shows the result. self.close_modals_for_open_keeping( ModalSurface::ColorPicker, @@ -213,6 +231,7 @@ impl InputState { } Some(ColorPickerTarget::BoardPaper) => Cow::Borrowed("Paper Color"), Some(ColorPickerTarget::Selection) => Cow::Borrowed("Selection Color"), + Some(ColorPickerTarget::SelectionFill) => Cow::Borrowed("Fill Color"), Some(ColorPickerTarget::Tool) | None => Cow::Borrowed("Select Color"), } } @@ -267,7 +286,7 @@ impl InputState { // Shapes change on OK only, as one undo entry; a live preview // would record one per drag step. ColorPickerPopupState::Open { - target: ColorPickerTarget::Selection, + target: ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill, .. } | ColorPickerPopupState::Hidden => {} @@ -351,6 +370,14 @@ impl InputState { self.note_recent_color(color); } } + if let Some((_, ColorPickerTarget::SelectionFill, _, color)) = applied_color + && self.selection_fill_paint_changes(Some(color), RecolorOpacity::Exact) + { + self.finish_active_arrow_bend(); + if self.apply_selection_fill_paint_with(measurer, Some(color), RecolorOpacity::Exact) { + self.note_recent_color(color); + } + } if let Some((tool, target, original_color, color)) = applied_color && original_color != color { @@ -383,7 +410,7 @@ impl InputState { // whether the board changes, so nothing is dirty yet. ColorPickerTarget::BoardPaper => {} // Applied above, whether or not the color moved. - ColorPickerTarget::Selection => {} + ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill => {} } } self.color_picker_popup.hide(); diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 5ec4adc2..9c6fa9c4 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -52,7 +52,12 @@ impl InputState { } PropertiesPanelHit::NoFill(_) => self.set_selection_fill_from_panel(measurer, None), PropertiesPanelHit::MoreColors(_) => { - return self.open_color_picker_popup_for_selection_with_measurer(measurer); + return match entry.kind { + SelectionPropertyKind::Fill => { + self.open_color_picker_popup_for_selection_fill_with_measurer(measurer) + } + _ => self.open_color_picker_popup_for_selection_with_measurer(measurer), + }; } PropertiesPanelHit::StepDown(_) => { self.dispatch_selection_property(measurer, entry.kind, -1) @@ -141,11 +146,11 @@ impl InputState { measurer: &TextMeasurer, paint: Option, ) -> bool { - if !self.selection_fill_paint_changes(paint) { + if !self.selection_fill_paint_changes(paint, RecolorOpacity::Swatch) { return false; } self.finish_active_arrow_bend(); - self.apply_selection_fill_paint_with(measurer, paint) + self.apply_selection_fill_paint_with(measurer, paint, RecolorOpacity::Swatch) } fn set_selection_arrow_style_from_panel( diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs index bcd0fc35..8058b062 100644 --- a/src/input/state/core/properties/apply_selection/actions/fill.rs +++ b/src/input/state/core/properties/apply_selection/actions/fill.rs @@ -1,3 +1,4 @@ +use super::color::RecolorOpacity; use crate::draw::TextMeasurer; use crate::draw::{Color, Shape}; use crate::input::state::core::base::InputState; @@ -58,16 +59,17 @@ impl InputState { /// opacity, the way the border's swatches keep it; a translucent one /// brings its own. Turning the fill off keeps the color, so turning it /// back on restores it. - pub(in crate::input::state::core::properties) fn apply_selection_fill_paint_with( + pub(crate) fn apply_selection_fill_paint_with( &mut self, measurer: &TextMeasurer, paint: Option, + opacity: RecolorOpacity, ) -> bool { let result = self.apply_selection_change_with( measurer, - |shape| filled(shape, paint).is_some(), + |shape| filled(shape, paint, opacity).is_some(), |shape| { - let Some(next) = filled(shape, paint) else { + let Some(next) = filled(shape, paint, opacity) else { return false; }; let Some((fill, fill_color)) = fill_fields(shape) else { @@ -84,19 +86,51 @@ impl InputState { self.report_selection_apply_result(result, "fill") } + /// The fill of the first editable selected closed shape, for the color + /// picker to open on; `None` when no closed shape can be filled. + pub(crate) fn selection_fill_paint_source(&self) -> Option { + let frame = self.boards.active_frame(); + self.selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .filter(|drawn| !drawn.locked) + .find_map(|drawn| fill_paint(&drawn.shape)) + } + /// Whether filling the selection with `paint` would change any shape it /// may edit, so picking the fill a selection already has is quiet. - pub(crate) fn selection_fill_paint_changes(&self, paint: Option) -> bool { + pub(crate) fn selection_fill_paint_changes( + &self, + paint: Option, + opacity: RecolorOpacity, + ) -> bool { let frame = self.boards.active_frame(); self.selected_shape_ids() .iter() .filter_map(|id| frame.shape(*id)) .filter(|drawn| !drawn.locked) - .filter_map(|drawn| filled(&drawn.shape, paint).zip(fill_state(&drawn.shape))) + .filter_map(|drawn| filled(&drawn.shape, paint, opacity).zip(fill_state(&drawn.shape))) .any(|(next, current)| next != current) } } +/// The fill a closed shape shows, or would show once filled: its own fill +/// color, or its border's. +fn fill_paint(shape: &Shape) -> Option { + match shape { + Shape::Rect { + fill_color, color, .. + } + | Shape::Ellipse { + fill_color, color, .. + } + | Shape::Polygon { + fill_color, color, .. + } => Some(fill_color.unwrap_or(*color)), + _ => None, + } +} + /// A closed shape's `(fill, fill_color)`, or `None` for a shape with no fill. fn fill_state(shape: &Shape) -> Option<(bool, Option)> { match shape { @@ -115,7 +149,11 @@ fn fill_state(shape: &Shape) -> Option<(bool, Option)> { /// The `(fill, fill_color)` a fill with `paint` leaves on `shape`, or `None` /// for a shape that has no fill. -fn filled(shape: &Shape, paint: Option) -> Option<(bool, Option)> { +fn filled( + shape: &Shape, + paint: Option, + opacity: RecolorOpacity, +) -> Option<(bool, Option)> { let (current_fill_color, border) = match shape { Shape::Rect { fill_color, color, .. @@ -131,7 +169,11 @@ fn filled(shape: &Shape, paint: Option) -> Option<(bool, Option)> Some(match paint { None => (false, current_fill_color), Some(swatch) => { - let alpha = if swatch.a >= 1.0 { border.a } else { swatch.a }; + let alpha = if opacity == RecolorOpacity::Swatch && swatch.a >= 1.0 { + border.a + } else { + swatch.a + }; (true, Some(Color { a: alpha, ..swatch })) } }) diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs index edc99272..7db2214c 100644 --- a/src/input/state/core/properties/panel_layout/geometry.rs +++ b/src/input/state/core/properties/panel_layout/geometry.rs @@ -12,8 +12,8 @@ use super::super::metrics::{ }; use super::super::types::{ PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyValue, - ShapePropertiesPanel, + PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind, + SelectionPropertyValue, ShapePropertiesPanel, }; use super::super::utils::palette_position; use crate::draw::ArrowStyle; @@ -40,14 +40,27 @@ fn row_shape(entry: &SelectionPropertyEntry) -> RowShape { } } -/// Lines the swatch grid needs for `swatches` plus its "more colors" button. -fn swatch_lines(swatches: usize) -> usize { - (swatches + 1).div_ceil(SWATCH_ITEMS_PER_LINE) +/// Whether a swatch row leads with a "no fill" cell: the fill row does. +fn leads_with_no_fill(entry: &SelectionPropertyEntry) -> bool { + matches!(entry.state, SelectionPropertyValue::Fill(_)) } -/// Width of the swatch grid's widest line. -pub(in crate::input::state::core::properties) fn swatch_grid_width(swatches: usize) -> f64 { - let items = (swatches + 1).min(SWATCH_ITEMS_PER_LINE) as f64; +/// Cells in a swatch row: its swatches, the trailing "more colors" button, +/// and the fill row's leading "no fill". +pub(in crate::input::state::core::properties) fn swatch_cells( + entry: &SelectionPropertyEntry, + swatches: usize, +) -> usize { + swatches + 1 + usize::from(leads_with_no_fill(entry)) +} + +fn swatch_lines(cells: usize) -> usize { + cells.div_ceil(SWATCH_ITEMS_PER_LINE) +} + +/// Width of a swatch grid's widest line. +pub(in crate::input::state::core::properties) fn swatch_grid_width(cells: usize) -> f64 { + let items = cells.min(SWATCH_ITEMS_PER_LINE) as f64; items * SWATCH_SIZE + (items - 1.0) * SWATCH_GAP } @@ -57,7 +70,7 @@ pub(in crate::input::state::core::properties) fn row_height( ) -> f64 { let control_height = match entry.state { SelectionPropertyValue::Color(_) | SelectionPropertyValue::Fill(_) => { - let lines = swatch_lines(swatches) as f64; + let lines = swatch_lines(swatch_cells(entry, swatches)) as f64; lines * SWATCH_SIZE + (lines - 1.0) * SWATCH_LINE_GAP } SelectionPropertyValue::ArrowStyle(_) => STYLE_BUTTON_HEIGHT, @@ -257,10 +270,7 @@ impl PropertiesPanelLayout { SelectionPropertyValue::ArrowStyle(_) => { self.arrow_style_buttons(left, control_top) } - SelectionPropertyValue::Fill(_) => { - swatch_grid(left, control_top, swatches, SwatchExtra::NoFill) - } - _ => swatch_grid(left, control_top, swatches, SwatchExtra::MoreColors), + _ => swatch_grid(left, control_top, swatches, leads_with_no_fill(entry)), }; return PropertiesRowGeometry { index, @@ -511,16 +521,9 @@ impl PropertiesPanelLayout { } } -/// The extra cell a swatch grid carries beside its colors. -#[derive(Clone, Copy, PartialEq, Eq)] -enum SwatchExtra { - /// A leading "no fill" cell, for the fill row. - NoFill, - /// A trailing "more colors" button, for the color row. - MoreColors, -} - -fn swatch_grid(x: f64, top: f64, swatches: usize, extra: SwatchExtra) -> PropertiesRowControl { +/// A swatch row's cells: an optional leading "no fill", the swatches, and a +/// trailing "more colors" button that opens the full color picker. +fn swatch_grid(x: f64, top: f64, swatches: usize, no_fill: bool) -> PropertiesRowControl { let cell = |item: usize| { let line = item / SWATCH_ITEMS_PER_LINE; let column = item % SWATCH_ITEMS_PER_LINE; @@ -531,17 +534,11 @@ fn swatch_grid(x: f64, top: f64, swatches: usize, extra: SwatchExtra) -> Propert SWATCH_SIZE, ) }; - match extra { - SwatchExtra::NoFill => PropertiesRowControl::Swatches { - swatches: (1..=swatches).map(cell).collect(), - none: Some(cell(0)), - more: None, - }, - SwatchExtra::MoreColors => PropertiesRowControl::Swatches { - swatches: (0..swatches).map(cell).collect(), - none: None, - more: Some(cell(swatches)), - }, + let first = usize::from(no_fill); + PropertiesRowControl::Swatches { + swatches: (first..first + swatches).map(cell).collect(), + none: no_fill.then(|| cell(0)), + more: Some(cell(first + swatches)), } } @@ -585,7 +582,13 @@ impl ShapePropertiesPanel { PropertiesPanelHit::Swatch { index, .. } => { self.swatches.get(index).map(|swatch| swatch.label.clone()) } - PropertiesPanelHit::MoreColors(_) => Some("More colors…".to_string()), + PropertiesPanelHit::MoreColors(row) => Some( + match self.entries.get(row).map(|entry| entry.kind) { + Some(SelectionPropertyKind::Fill) => "More fill colors…", + _ => "More colors…", + } + .to_string(), + ), PropertiesPanelHit::NoFill(_) => Some("No fill".to_string()), PropertiesPanelHit::Action(action) => match action { PanelAction::ToBack => Some("Send to back".to_string()), diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index e69c79a0..ceb9c494 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -15,7 +15,9 @@ use super::super::types::{ PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, SelectionPropertyValue, ShapePropertiesPanel, }; -use super::geometry::{balanced_column_budget, column_slots, row_height, swatch_grid_width}; +use super::geometry::{ + balanced_column_budget, column_slots, row_height, swatch_cells, swatch_grid_width, +}; use super::{PANEL_ANCHOR_GAP, PANEL_MARGIN}; use crate::draw::ArrowStyle; use crate::ui_text::UiTextEngine; @@ -109,7 +111,7 @@ fn measure_panel( for entry in &panel.entries { let row_width = match entry.state { SelectionPropertyValue::Color(_) | SelectionPropertyValue::Fill(_) => { - label_width.max(swatch_grid_width(panel.swatches.len())) + label_width.max(swatch_grid_width(swatch_cells(entry, panel.swatches.len()))) } SelectionPropertyValue::ArrowStyle(_) => { let count = ArrowStyle::ALL.len() as f64; diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index e0ed7caa..e0b5a760 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -572,6 +572,79 @@ fn cancelling_the_selection_picker_leaves_the_shapes_alone() { assert!(state.is_properties_panel_open()); } +#[test] +fn more_fill_colors_opens_a_fill_picker_and_ok_fills_with_exactly_that_color() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + let translucent = Color { + r: 0.2, + g: 0.4, + b: 0.6, + a: 0.4, + }; + assert_eq!( + state + .properties_panel() + .unwrap() + .tooltip(PropertiesPanelHit::MoreColors(fill)), + Some("More fill colors…".to_string()) + ); + + click(&mut state, PropertiesPanelHit::MoreColors(fill)); + + assert!(state.is_color_picker_popup_open()); + assert!( + state.is_properties_panel_open(), + "the panel stays open under the picker" + ); + assert_eq!(state.color_picker_popup_title(), "Fill Color"); + assert_eq!( + state.color_picker_popup_current_color(), + Some(PALETTE_RED), + "an unfilled shape would fill with its border color" + ); + + state.color_picker_popup_set_color(translucent); + assert_eq!( + fill_of(&state, id), + (false, None), + "nothing changes before OK" + ); + let depth = undo_depth(&state); + state.apply_color_picker_popup(); + + assert!(!state.is_color_picker_popup_open()); + assert_eq!( + fill_of(&state, id), + (true, Some(translucent)), + "the picker's opacity is the fill's own" + ); + assert_eq!( + rect_of(&state, id).0, + PALETTE_RED, + "the border keeps its color" + ); + assert_eq!(undo_depth(&state), depth + 1); + assert_eq!(state.recent_colors().first(), Some(&translucent)); +} + +#[test] +fn cancelling_the_fill_picker_leaves_the_fill_alone() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + click(&mut state, PropertiesPanelHit::MoreColors(fill)); + + state.color_picker_popup_set_color(PALETTE_GREEN); + state.close_color_picker_popup(true); + + assert_eq!(fill_of(&state, id), (false, None)); + assert!(state.is_properties_panel_open()); +} + #[test] fn ok_on_a_mixed_selection_applies_even_the_color_it_opened_on() { let mut state = create_test_input_state(); From fc2b36e612aab1f8aec863e4702464ee57e66e5b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:49:22 +0200 Subject: [PATCH 12/12] feat: point out Alt+click selection in onboarding The first-run quick-access card mentions that Alt+click selects a shape without switching tools. The guided tour's menus step, now "Menus & Selection", adds Alt+click, Alt+drag to move or box-select, and the properties shortcut from the live keymap. --- .../state/onboarding/first_run_card.rs | 5 +- src/input/state/core/tour.rs | 55 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/backend/wayland/state/onboarding/first_run_card.rs b/src/backend/wayland/state/onboarding/first_run_card.rs index a2cfdb19..a6b8cf07 100644 --- a/src/backend/wayland/state/onboarding/first_run_card.rs +++ b/src/backend/wayland/state/onboarding/first_run_card.rs @@ -66,7 +66,10 @@ impl WaylandState { FirstRunStep::QuickAccess => OnboardingCard { eyebrow, title: "Quick access at cursor".to_string(), - body: "Open quick actions near the pointer.".to_string(), + // Alt+click is a fixed mouse gesture, not a binding. + body: "Open quick actions near the pointer. Alt+click a shape to select it \ + without switching tools." + .to_string(), items: self.quick_access_checklist_items(state), buttons: vec![skip_tour_button()], footer: String::new(), diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index e71fa252..159e577b 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -97,7 +97,7 @@ impl TourStep { Self::DrawingBasics => "Drawing Basics", Self::ToolbarIntro => "Toolbar Access", Self::CommandPalette => "Command Palette", - Self::ContextMenu => "Context Menu", + Self::ContextMenu => "Menus & Selection", Self::StatusBar => "Boards & Pages", Self::HelpOverlay => "Help & Shortcuts", Self::Presets => "Quick Presets", @@ -194,10 +194,20 @@ impl InputState { ); lines.join("\n") } - TourStep::ContextMenu => "Right-click anywhere for quick actions.\n\ - Access boards, pages, and common commands.\n\ - Shape-specific options when clicking on shapes." - .to_string(), + TourStep::ContextMenu => { + let mut lines = vec![ + "Right-click anywhere for quick actions.".to_string(), + "Access boards, pages, and common commands.".to_string(), + "Shape-specific options when clicking on shapes.".to_string(), + // Alt+click is a fixed mouse gesture, not a binding. + "Alt+click a shape to select it without switching tools.".to_string(), + "Alt+drag moves shapes, or box-selects on empty canvas.".to_string(), + ]; + if let Some(key) = self.shortcut_for_action(Action::ToggleSelectionProperties) { + lines.push(format!("{key} opens the selection's properties.")); + } + lines.join("\n") + } TourStep::StatusBar => { let board = self.ui_visibility.show_status_board_badge && self.boards.show_badge(); let page = self.ui_visibility.show_status_page_badge; @@ -472,6 +482,41 @@ mod tests { ); } + #[test] + fn menus_step_points_out_alt_click_selection() { + use crate::config::{KeybindingsConfig, Shortcut}; + + let mut state = make_test_input_state(); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); + assert!(copy.contains("Alt+drag"), "copy: {copy:?}"); + let properties = state + .shortcut_for_action(Action::ToggleSelectionProperties) + .expect("properties bound"); + assert!(copy.contains(&properties), "copy: {copy:?}"); + + // An unbound properties panel drops its line rather than naming a key. + let mut bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default bindings"); + bindings.insert(Action::ToggleSelectionProperties, Vec::new()); + state.set_action_bindings(bindings); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); + assert!(!copy.contains("properties"), "copy: {copy:?}"); + + let mut bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default bindings"); + bindings.insert( + Action::ToggleSelectionProperties, + vec![Shortcut::parse("Ctrl+Shift+I").expect("binding")], + ); + state.set_action_bindings(bindings); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Ctrl+Shift+I"), "copy: {copy:?}"); + } + #[test] fn starting_tour_exits_focus_mode_before_tour_owns_chrome() { let test_text_measurer = crate::draw::TextMeasurer::default();