From f0055ff718136bf4c713421fa0fa4eacc9c94541 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sun, 27 Sep 2026 08:32:00 +0200
Subject: [PATCH] feat: add selection property controls and expand toolbar
palette
---
README.md | 4 +-
src/backend/wayland/handlers/pointer/axis.rs | 29 +
src/backend/wayland/state/render/ui.rs | 44 +-
.../wayland/toolbar/layout/tests/mod.rs | 7 +-
src/backend/wayland/toolbar/view/top.rs | 4 +-
src/backend/wayland/toolbar/view/top/tests.rs | 2 +
src/input/mod.rs | 2 +-
.../state/core/color_picker_popup/mod.rs | 8 +-
.../state/core/color_picker_popup/state.rs | 51 +-
src/input/state/core/mod.rs | 7 +-
src/input/state/core/modal.rs | 4 +-
src/input/state/core/properties/apply.rs | 154 +++-
.../apply_selection/actions/arrow.rs | 25 +-
.../apply_selection/actions/color.rs | 269 +++----
.../properties/apply_selection/helpers.rs | 2 +-
src/input/state/core/properties/entries.rs | 283 +++++---
src/input/state/core/properties/metrics.rs | 96 +++
src/input/state/core/properties/mod.rs | 5 +-
src/input/state/core/properties/panel.rs | 397 +++++++----
.../core/properties/panel_layout/focus.rs | 8 +-
.../core/properties/panel_layout/geometry.rs | 470 ++++++++++++
.../properties/panel_layout/interaction.rs | 128 +++-
.../core/properties/panel_layout/layout.rs | 674 ++++++++++--------
.../state/core/properties/panel_layout/mod.rs | 12 +-
.../core/properties/panel_layout/tests.rs | 385 ++++++++++
src/input/state/core/properties/state.rs | 10 +-
src/input/state/core/properties/types.rs | 224 +++++-
src/input/state/core/properties/utils.rs | 87 ++-
src/input/state/mod.rs | 15 +-
src/input/state/mouse/press/panels.rs | 4 +-
src/input/state/mouse/release/panels.rs | 7 +-
src/input/state/tests/mod.rs | 1 +
src/input/state/tests/properties_panel.rs | 29 +-
.../state/tests/properties_panel_controls.rs | 546 ++++++++++++++
.../view/top_bar/tests/expectations.rs | 1 +
src/ui/properties_panel.rs | 400 +++++++----
src/ui/properties_panel/controls.rs | 442 ++++++++++++
src/ui/toolbar/model/style_pill.rs | 2 +-
.../model/style_pill/tests/selection.rs | 1 +
.../model/style_pill/tests/tool_states.rs | 17 +
src/ui/toolbar/model/top_spec/spec.rs | 4 +-
41 files changed, 3951 insertions(+), 909 deletions(-)
create mode 100644 src/input/state/core/properties/metrics.rs
create mode 100644 src/input/state/core/properties/panel_layout/geometry.rs
create mode 100644 src/input/state/core/properties/panel_layout/tests.rs
create mode 100644 src/input/state/tests/properties_panel_controls.rs
create mode 100644 src/ui/properties_panel/controls.rs
diff --git a/README.md b/README.md
index 9a4c5fd02..06757692a 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
+- 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
- 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
@@ -1176,7 +1176,7 @@ Drag modifier mappings are configurable via `[drawing]` (`drag_tool`, `shift_dra
### Quick colors
-The quick color palette is configurable with ordered `[[drawing.quick_colors]]` entries. The first eight entries map to the R/G/B/Y/O/P/W/K shortcuts; if fewer are configured by hand, missing shortcut positions use the built-in defaults. The implicit default toolbar palette also preserves Cyan, Purple, and Gray as expanded toolbar colors while the radial menu keeps its original first-eight color ring. Explicit entries beyond the first eight have no shortcut action binding and opt those extra colors into dense palette UIs, capped to the first 24 colors.
+The quick color palette is configurable with ordered `[[drawing.quick_colors]]` entries. The first eight entries map to the R/G/B/Y/O/P/W/K shortcuts; if fewer are configured by hand, missing shortcut positions use the built-in defaults. The implicit default toolbar palette also preserves Cyan, Purple, and Gray as expanded toolbar colors while the radial menu keeps its original first-eight color ring. Explicit entries beyond the first eight have no shortcut action binding and opt those extra colors into dense palette UIs, capped to the first 24 colors. The top toolbar's style pill shows up to 12 of them (fewer when the strip is short of width) and the selection properties panel up to 17; the color picker reaches any color.
You can also recolor the palette without opening an editor: **right-click any swatch** to open the color picker for that slot. The swatch updates live as you drag, OK saves that one entry's color back to `config.toml` (leaving your other settings and comments alone, with a timestamped `.bak`), and Cancel restores it. The slot keeps its label and shortcut, so R still selects the red slot after you point it at a different red. Recoloring the swatch you are drawing with moves the live color along with it. Left-click still just selects a swatch, and the leftmost chip still opens the picker for the active tool's own color.
diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs
index 6055a4fd9..2817b8bdf 100644
--- a/src/backend/wayland/handlers/pointer/axis.rs
+++ b/src/backend/wayland/handlers/pointer/axis.rs
@@ -194,6 +194,17 @@ impl WaylandState {
) {
return;
}
+ if routed.surface == InputSurface::Canvas
+ && !self.toolbar_chrome.pointer_over_toolbar()
+ && try_handle_properties_panel_axis(
+ &mut self.input_state,
+ self.render.text_measurer(),
+ event.position,
+ scroll_direction,
+ )
+ {
+ return;
+ }
let over_toolbar =
routed.surface == InputSurface::Toolbar || self.toolbar_chrome.pointer_over_toolbar();
let over_top_toolbar =
@@ -383,6 +394,24 @@ fn try_handle_board_picker_page_panel_axis(
true
}
+/// A wheel tick over the properties panel steps the row under it, and one
+/// anywhere else on the panel is swallowed rather than resizing the tool
+/// behind it. A surface covering the panel (the color picker it opened) keeps
+/// the tick.
+fn try_handle_properties_panel_axis(
+ input_state: &mut InputState,
+ measurer: &crate::draw::TextMeasurer,
+ position: (f64, f64),
+ scroll_direction: i32,
+) -> bool {
+ if scroll_direction == 0 || input_state.modal_owns_wheel() {
+ return false;
+ }
+ let x = position.0.round() as i32;
+ let y = position.1.round() as i32;
+ input_state.properties_panel_wheel_with(measurer, x, y, scroll_direction)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 568fb3574..3a9343114 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -38,9 +38,11 @@ impl WaylandState {
// Inline bars stand in for the layer-shell toolbar surfaces, but every
// popup and modal below must paint above them.
self.render_inline_toolbar_chrome(ctx, capture_picker);
+ // Below the pickers: the color picker the panel opens has to cover it.
+ self.render_properties_panel(ctx, width, height, capture_picker);
self.render_help_and_pickers(ctx, width, height, capture_picker);
self.render_radial_menu_and_feedback(ctx, width, height, capture_picker);
- self.render_properties_and_context(ctx, width, height, capture_picker);
+ self.render_context_menu(ctx, capture_picker);
self.render_inline_and_modal_ui(ctx, width, height, capture_picker);
}
@@ -358,34 +360,27 @@ impl WaylandState {
crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height);
}
- fn render_properties_and_context(
+ fn render_properties_panel(
&mut self,
ctx: &cairo::Context,
width: u32,
height: u32,
capture_picker: bool,
) {
- // Board and page menus open on top of the board picker, so the picker
- // must not hide this pass. It never shares the screen with the
- // properties panel. Both surfaces are laid out in screen space from
- // zoom-aware anchors, so they draw over a zoomed view too.
- if capture_picker {
- self.input_state.clear_context_menu_layout();
+ // Laid out in screen space from a zoom-aware anchor, so it draws over
+ // a zoomed view too.
+ if capture_picker || !self.input_state.is_properties_panel_open() {
self.input_state.clear_properties_panel_layout();
return;
}
- if self.input_state.is_properties_panel_open() {
- self.input_state
- .update_properties_panel_layout_with_resources(
- self.render.ui_text(),
- self.render.text_measurer(),
- ctx,
- width,
- height,
- );
- } else {
- self.input_state.clear_properties_panel_layout();
- }
+ self.input_state
+ .update_properties_panel_layout_with_resources(
+ self.render.ui_text(),
+ self.render.text_measurer(),
+ ctx,
+ width,
+ height,
+ );
crate::ui::render_properties_panel_with_engine(
self.render.ui_text(),
ctx,
@@ -393,6 +388,15 @@ impl WaylandState {
width,
height,
);
+ }
+
+ fn render_context_menu(&mut self, ctx: &cairo::Context, capture_picker: bool) {
+ // Board and page menus open on top of the board picker, so the picker
+ // must not hide this pass.
+ if capture_picker {
+ self.input_state.clear_context_menu_layout();
+ return;
+ }
// An open menu was already laid out along with this frame's damage.
if !self.input_state.is_context_menu_open() {
self.input_state.clear_context_menu_layout();
diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs
index b23ae72ee..4bbfeb909 100644
--- a/src/backend/wayland/toolbar/layout/tests/mod.rs
+++ b/src/backend/wayland/toolbar/layout/tests/mod.rs
@@ -52,14 +52,17 @@ fn narrow_viewports_drop_presets_then_overflow_items() {
state.set_toolbar_use_icons(true);
let mut snapshot = snapshot_from_state(&state);
- // Unconstrained: presets shown, the pill's eight swatches available,
+ // Unconstrained: presets shown, the pill's full swatch row available,
// nothing dropped into the overflow.
let full = crate::backend::wayland::toolbar::view::top::plan_top_strip(
&crate::ui_text::UiTextEngine::default(),
&snapshot,
);
assert!(!full.drop_presets);
- assert_eq!(full.swatch_count, 8);
+ assert_eq!(
+ full.swatch_count,
+ crate::ui::toolbar::model::TopStripPlan::MAX_QUICK_COLORS
+ );
assert!(full.dropped_tools.is_empty() && full.dropped_utilities.is_empty());
let full_width = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0;
diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs
index 18796926e..ade4a209a 100644
--- a/src/backend/wayland/toolbar/view/top.rs
+++ b/src/backend/wayland/toolbar/view/top.rs
@@ -58,7 +58,7 @@ const TOP_COMPACT_ISLAND_PAD: f64 = crate::ui::theme::toolbar::COMPACT_ISLAND_PA
pub(crate) use model::TopStripPlan;
/// Degrade the strip until it fits the viewport: quick swatches shrink
-/// 8→6→4→0 first, then droppable items move into the overflow menu.
+/// 12→8→6→4→0 first, then droppable items move into the overflow menu.
pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopStripPlan {
let mut plan = TopStripPlan::unconstrained();
if snapshot.top_minimized || snapshot.top_micro_active() {
@@ -77,7 +77,7 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS
if fits(&plan) {
return plan;
}
- for count in [6, 4, 0] {
+ for count in [8, 6, 4, 0] {
plan.swatch_count = count;
if fits(&plan) {
return plan;
diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs
index fb5aac6e7..3b86c6aaa 100644
--- a/src/backend/wayland/toolbar/view/top/tests.rs
+++ b/src/backend/wayland/toolbar/view/top/tests.rs
@@ -1171,12 +1171,14 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() {
label: "Color".to_string(),
value: "Red".to_string(),
kind: crate::input::SelectionPropertyKind::Color,
+ state: crate::input::SelectionPropertyKind::Color.unknown_value(),
disabled: false,
},
crate::input::SelectionPropertyEntry {
label: "Thickness".to_string(),
value: "3.0px".to_string(),
kind: crate::input::SelectionPropertyKind::Thickness,
+ state: crate::input::SelectionPropertyKind::Thickness.unknown_value(),
disabled: false,
},
];
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 82a1945d2..cfa048a97 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -26,7 +26,7 @@ pub use state::{
HelpOverlayCursorHint, HelpOverlayReleaseOutcome, InputHudActiveSource, InputHudEntry,
InputHudEntryKind, InputHudSettings, InputState, OutputFocusAction, RegionInputSource,
RegionPurposeTag, RegionSelectUiState, SelectionHandle, SelectionPropertyEntry,
- SelectionPropertyKind, TextInputMode, ZoomAction,
+ SelectionPropertyKind, SelectionPropertyValue, TextInputMode, ZoomAction,
};
#[allow(unused_imports)]
pub(crate) use state::{DrawingStyle, IdleHandle};
diff --git a/src/input/state/core/color_picker_popup/mod.rs b/src/input/state/core/color_picker_popup/mod.rs
index d5746b5c9..0ed626b98 100644
--- a/src/input/state/core/color_picker_popup/mod.rs
+++ b/src/input/state/core/color_picker_popup/mod.rs
@@ -96,6 +96,10 @@ pub enum ColorPickerTarget {
QuickColor(usize),
/// The paper color draft of the board picker's appearance sheet.
BoardPaper,
+ /// The selected shapes, opened from the properties panel. Nothing is
+ /// previewed live: OK recolors the selection in one undoable edit and
+ /// Cancel leaves it untouched.
+ Selection,
}
impl ColorPickerTarget {
@@ -103,7 +107,9 @@ impl ColorPickerTarget {
pub fn slot(self) -> Option {
match self {
ColorPickerTarget::QuickColor(index) => Some(index),
- ColorPickerTarget::Tool | ColorPickerTarget::BoardPaper => None,
+ ColorPickerTarget::Tool
+ | ColorPickerTarget::BoardPaper
+ | ColorPickerTarget::Selection => None,
}
}
diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs
index 43756ac5a..16b304b64 100644
--- a/src/input/state/core/color_picker_popup/state.rs
+++ b/src/input/state/core/color_picker_popup/state.rs
@@ -89,6 +89,25 @@ impl InputState {
true
}
+ /// Opens the popup on the selection's color, from the properties panel's
+ /// "More colors" button. The panel stays open underneath so the accepted
+ /// color shows up in it. Returns false when nothing selected has a color.
+ pub(crate) fn open_color_picker_popup_for_selection_with_measurer(
+ &mut self,
+ measurer: &TextMeasurer,
+ ) -> bool {
+ if !self.selection_has_editable_color() {
+ return false;
+ }
+ let color = self
+ .selection_primary_color()
+ .unwrap_or_else(|| self.color_for_tool(self.active_tool()));
+ self.discard_open_color_picker_recolor();
+ self.clear_properties_panel_pointer_state();
+ self.open_color_picker_popup_for(measurer, ColorPickerTarget::Selection, 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)
@@ -160,6 +179,12 @@ impl InputState {
ModalSurface::ColorPicker,
ModalSurface::BoardPicker,
);
+ } else if target == ColorPickerTarget::Selection {
+ // Opened from the properties panel, which shows the result.
+ self.close_modals_for_open_keeping(
+ ModalSurface::ColorPicker,
+ ModalSurface::PropertiesPanel,
+ );
} else {
self.close_modals_for_open(ModalSurface::ColorPicker);
}
@@ -186,6 +211,7 @@ impl InputState {
}
}
Some(ColorPickerTarget::BoardPaper) => Cow::Borrowed("Paper Color"),
+ Some(ColorPickerTarget::Selection) => Cow::Borrowed("Selection Color"),
Some(ColorPickerTarget::Tool) | None => Cow::Borrowed("Select Color"),
}
}
@@ -237,7 +263,13 @@ impl InputState {
// The sheet's draft is the target; its own Apply commits.
let _ = self.board_appearance_palette(opaque(color));
}
- ColorPickerPopupState::Hidden => {}
+ // Shapes change on OK only, as one undo entry; a live preview
+ // would record one per drag step.
+ ColorPickerPopupState::Open {
+ target: ColorPickerTarget::Selection,
+ ..
+ }
+ | ColorPickerPopupState::Hidden => {}
}
}
@@ -269,6 +301,10 @@ impl InputState {
/// Applies the current color and closes the popup.
pub fn apply_color_picker_popup(&mut self) {
+ self.apply_color_picker_popup_with_measurer(&TextMeasurer::default());
+ }
+
+ fn apply_color_picker_popup_with_measurer(&mut self, measurer: &TextMeasurer) {
self.cancel_pending_color_picker_paste();
let mut applied_color = None;
if let ColorPickerPopupState::Open {
@@ -303,6 +339,17 @@ impl InputState {
// has to put it back.
self.color_picker_popup_preview(color);
}
+ // Decided by the shapes, not by the color the popup opened on: that
+ // 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.finish_active_arrow_bend();
+ if self.apply_selection_color_value_with(measurer, color) {
+ self.note_recent_color(color);
+ }
+ }
if let Some((tool, target, original_color, color)) = applied_color
&& original_color != color
{
@@ -334,6 +381,8 @@ impl InputState {
// The draft already holds the color; the sheet's Apply decides
// whether the board changes, so nothing is dirty yet.
ColorPickerTarget::BoardPaper => {}
+ // Applied above, whether or not the color moved.
+ ColorPickerTarget::Selection => {}
}
}
self.color_picker_popup.hide();
diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs
index 0604b87b2..56b9345ad 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -124,7 +124,12 @@ pub use menus::{
ContextSubmenu, MenuCommand, SubmenuSide,
};
pub use menus::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY};
-pub use properties::{SelectionPropertyEntry, SelectionPropertyKind};
+pub(crate) use properties::metrics as properties_panel_metrics;
+pub use properties::{
+ PanelRect, 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,
RadialMenuPanel, RadialMenuState, RadialParent, RadialRingSwatch, RadialSegmentId, RadialSlice,
diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs
index 17deaf98d..a717a844e 100644
--- a/src/input/state/core/modal.rs
+++ b/src/input/state/core/modal.rs
@@ -85,7 +85,9 @@ impl ModalSurface {
///
/// The properties panel is deliberately out. It docks beside the canvas
/// rather than over it, and the canvas stays drawable underneath — so the
- /// wheel still means what it means everywhere else.
+ /// wheel still means what it means everywhere else. A tick over the panel
+ /// itself steps the row under the pointer; the axis handler routes that
+ /// before it gets here.
fn owns_wheel(self) -> bool {
!matches!(self, ModalSurface::PropertiesPanel)
}
diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs
index fe32ba3b2..8e2818677 100644
--- a/src/input/state/core/properties/apply.rs
+++ b/src/input/state/core/properties/apply.rs
@@ -1,8 +1,153 @@
use super::super::base::InputState;
-use super::types::SelectionPropertyKind;
-use crate::draw::{Shape, TextMeasurer};
+use super::types::{
+ PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry, SelectionPropertyKind,
+ SelectionPropertyValue,
+};
+use crate::draw::{ArrowStyle, Color, Shape, TextMeasurer};
impl InputState {
+ /// Acts on the panel control `hit`: a swatch sets that color, a stepper
+ /// button steps its property, and so on. Returns whether anything changed
+ /// (for the "More colors" button: whether the color picker opened).
+ pub(crate) fn activate_properties_panel_hit_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ hit: PropertiesPanelHit,
+ ) -> bool {
+ if hit == PropertiesPanelHit::Lock {
+ return self.toggle_properties_panel_lock_with(measurer);
+ }
+ 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::Swatch { index, .. } => {
+ let Some(color) = self
+ .properties
+ .panel
+ .as_ref()
+ .and_then(|panel| panel.swatches.get(index))
+ .map(|swatch| swatch.color)
+ else {
+ return false;
+ };
+ self.set_selection_color_from_panel(measurer, color)
+ }
+ PropertiesPanelHit::MoreColors(_) => {
+ return self.open_color_picker_popup_for_selection_with_measurer(measurer);
+ }
+ PropertiesPanelHit::StepDown(_) => {
+ self.dispatch_selection_property(measurer, entry.kind, -1)
+ }
+ PropertiesPanelHit::StepUp(_) => {
+ self.dispatch_selection_property(measurer, entry.kind, 1)
+ }
+ PropertiesPanelHit::Toggle(_) => {
+ self.dispatch_selection_property(measurer, entry.kind, 0)
+ }
+ PropertiesPanelHit::ArrowHead { at_end, .. } => {
+ if entry.state == SelectionPropertyValue::ArrowHead(Some(at_end)) {
+ return false;
+ }
+ let direction = if at_end { 1 } else { -1 };
+ self.dispatch_selection_property(measurer, entry.kind, direction)
+ }
+ PropertiesPanelHit::ArrowStyle { style, .. } => {
+ if entry.state == SelectionPropertyValue::ArrowStyle(Some(style)) {
+ return false;
+ }
+ self.set_selection_arrow_style_from_panel(measurer, style)
+ }
+ // A click beside a switch flips it, the way a click on a checkbox
+ // label does; every other row only takes focus.
+ PropertiesPanelHit::Row(_) => match entry.state {
+ SelectionPropertyValue::Toggle(_) => {
+ self.dispatch_selection_property(measurer, entry.kind, 0)
+ }
+ _ => false,
+ },
+ };
+
+ if changed {
+ self.refresh_properties_panel_with(measurer);
+ }
+ changed
+ }
+
+ /// Steps the row under a wheel tick: up raises a number, turns a switch
+ /// on, or moves to the next swatch or style, and down does the opposite.
+ pub(crate) fn step_properties_panel_row_by_wheel_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ row: usize,
+ scroll_direction: i32,
+ ) -> bool {
+ if scroll_direction == 0 {
+ return false;
+ }
+ let Some(entry) = self.enabled_properties_entry(row) else {
+ return false;
+ };
+
+ let changed = self.dispatch_selection_property(measurer, entry.kind, -scroll_direction);
+
+ if changed {
+ self.refresh_properties_panel_with(measurer);
+ }
+ changed
+ }
+
+ fn enabled_properties_entry(&self, row: usize) -> Option {
+ self.properties
+ .panel
+ .as_ref()?
+ .entries
+ .get(row)
+ .filter(|entry| !entry.disabled)
+ .cloned()
+ }
+
+ fn set_selection_color_from_panel(&mut self, measurer: &TextMeasurer, color: Color) -> bool {
+ // 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) {
+ return false;
+ }
+ self.finish_active_arrow_bend();
+ self.apply_selection_color_value_with(measurer, color)
+ }
+
+ fn set_selection_arrow_style_from_panel(
+ &mut self,
+ measurer: &TextMeasurer,
+ style: ArrowStyle,
+ ) -> bool {
+ // See `dispatch_selection_property`: a restyle must end a live bend
+ // drag before it records its own undo entry.
+ self.finish_active_arrow_bend();
+ self.apply_selection_arrow_style_value(measurer, style)
+ }
+
+ /// 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.
+ fn toggle_properties_panel_lock_with(&mut self, measurer: &TextMeasurer) -> bool {
+ let Some(lock) = self.properties.panel.as_ref().map(|panel| panel.lock) else {
+ return false;
+ };
+ let lock_all = lock != PropertiesPanelLock::Locked;
+
+ let changed = self.set_selection_locked_with(measurer, lock_all);
+
+ if changed {
+ self.refresh_properties_panel_with(measurer);
+ }
+ changed
+ }
+
pub(crate) fn activate_properties_panel_entry_with(&mut self, measurer: &TextMeasurer) -> bool {
self.adjust_properties_panel_entry_with(measurer, 0)
}
@@ -157,6 +302,7 @@ impl InputState {
}
fn direction_or_default(direction: i32) -> i32 {
- // Treat activation (0) as a forward step; preserve negative direction.
- if direction < 0 { -1 } else { 1 }
+ // Treat activation (0) as a forward step. The magnitude is kept, so a
+ // coarse keyboard step moves a number several steps at once.
+ if direction == 0 { 1 } else { direction }
}
diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs
index e70426554..42bb15f90 100644
--- a/src/input/state/core/properties/apply_selection/actions/arrow.rs
+++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs
@@ -71,7 +71,30 @@ impl InputState {
measurer: &TextMeasurer,
direction: i32,
) -> bool {
- let target = match self.selection_arrow_style_target(direction) {
+ let target = self.selection_arrow_style_target(direction);
+ self.restyle_selected_arrows(measurer, target)
+ }
+
+ /// Sets every editable selected arrow to `style`, as picking one of the
+ /// drawn styles does.
+ pub(in crate::input::state::core::properties) fn apply_selection_arrow_style_value(
+ &mut self,
+ measurer: &TextMeasurer,
+ style: ArrowStyle,
+ ) -> bool {
+ let target = match self.selection_arrow_style_target(0) {
+ ArrowStyleTarget::Style(_) => ArrowStyleTarget::Style(style),
+ other => other,
+ };
+ self.restyle_selected_arrows(measurer, target)
+ }
+
+ fn restyle_selected_arrows(
+ &mut self,
+ measurer: &TextMeasurer,
+ target: ArrowStyleTarget,
+ ) -> bool {
+ let target = match target {
ArrowStyleTarget::NoArrows => {
self.push_toast(
ToastPriority::Info,
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 a83248a77..93701e38e 100644
--- a/src/input/state/core/properties/apply_selection/actions/color.rs
+++ b/src/input/state/core/properties/apply_selection/actions/color.rs
@@ -1,9 +1,54 @@
use crate::draw::TextMeasurer;
-use crate::draw::{Color, RED, Shape};
+use crate::draw::{Color, Shape};
use crate::input::state::core::base::InputState;
-use crate::input::state::core::properties::utils::{
- SELECTION_COLORS, color_palette_index, cycle_index,
-};
+use crate::input::state::core::properties::utils::{color_palette_index, palette_step};
+use crate::input::state::{Toast, ToastPriority};
+
+/// 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 {
+ Shape::Freehand { color, .. }
+ | Shape::FreehandPressure { color, .. }
+ | Shape::Line { color, .. }
+ | Shape::Rect { color, .. }
+ | Shape::Ellipse { color, .. }
+ | Shape::Polygon { 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))
+}
+
+fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> {
+ match shape {
+ Shape::Freehand { color, .. }
+ | Shape::FreehandPressure { color, .. }
+ | Shape::Line { color, .. }
+ | Shape::Rect { color, .. }
+ | Shape::Ellipse { color, .. }
+ | Shape::Polygon { color, .. }
+ | Shape::Arrow { color, .. }
+ | Shape::MarkerStroke { color, .. }
+ | Shape::Text { color, .. }
+ | Shape::StepMarker { color, .. } => Some(color),
+ Shape::StickyNote { background, .. } => Some(background),
+ _ => None,
+ }
+}
impl InputState {
pub(crate) fn apply_selection_color_value_with(
@@ -13,125 +58,61 @@ impl InputState {
) -> bool {
let result = self.apply_selection_change_with(
measurer,
+ |shape| recolored(shape, target).is_some(),
|shape| {
- matches!(
- shape,
- Shape::Freehand { .. }
- | Shape::FreehandPressure { .. }
- | Shape::Line { .. }
- | Shape::Rect { .. }
- | Shape::Ellipse { .. }
- | Shape::Polygon { .. }
- | Shape::Arrow { .. }
- | Shape::MarkerStroke { .. }
- | Shape::Text { .. }
- | Shape::StepMarker { .. }
- | Shape::StickyNote { .. }
- )
- },
- |shape| match shape {
- Shape::Freehand { color, .. }
- | Shape::FreehandPressure { color, .. }
- | Shape::Line { color, .. }
- | Shape::Rect { color, .. }
- | Shape::Ellipse { color, .. }
- | Shape::Polygon { color, .. }
- | Shape::Arrow { color, .. }
- | Shape::Text { color, .. }
- | Shape::StepMarker { color, .. }
- if *color != target =>
- {
- *color = target;
- true
- }
- Shape::MarkerStroke { color, .. } => {
- let new_color = Color {
- a: color.a,
- ..target
- };
- if *color != new_color {
- *color = new_color;
- true
- } else {
- false
- }
+ let Some((current, next)) = recolored(shape, target) else {
+ return false;
+ };
+ if current == next {
+ return false;
}
- Shape::StickyNote { background, .. } if *background != target => {
- *background = target;
- true
- }
- _ => false,
+ let Some(color) = shape_color_mut(shape) else {
+ return false;
+ };
+ *color = next;
+ true
},
);
self.report_selection_apply_result(result, "color")
}
+ /// 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 {
+ 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))
+ .any(|(current, next)| current != next)
+ }
+
+ /// 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(
&mut self,
measurer: &TextMeasurer,
direction: i32,
) -> bool {
- let base_color = self.selection_primary_color().unwrap_or(RED);
- let index = color_palette_index(base_color).unwrap_or(0);
- let offset = if direction == 0 { 1 } else { direction };
- let next = cycle_index(index, SELECTION_COLORS.len(), offset);
- let target = SELECTION_COLORS[next].1;
-
- let result = self.apply_selection_change_with(
- measurer,
- |shape| {
- matches!(
- shape,
- Shape::Freehand { .. }
- | Shape::FreehandPressure { .. }
- | Shape::Line { .. }
- | Shape::Rect { .. }
- | Shape::Ellipse { .. }
- | Shape::Polygon { .. }
- | Shape::Arrow { .. }
- | Shape::MarkerStroke { .. }
- | Shape::Text { .. }
- | Shape::StepMarker { .. }
- | Shape::StickyNote { .. }
- )
- },
- |shape| match shape {
- Shape::Freehand { color, .. }
- | Shape::FreehandPressure { color, .. }
- | Shape::Line { color, .. }
- | Shape::Rect { color, .. }
- | Shape::Ellipse { color, .. }
- | Shape::Polygon { color, .. }
- | Shape::Arrow { color, .. }
- | Shape::Text { color, .. }
- | Shape::StepMarker { color, .. }
- if *color != target =>
- {
- *color = target;
- true
- }
- Shape::MarkerStroke { color, .. } => {
- let new_color = Color {
- a: color.a,
- ..target
- };
- if *color != new_color {
- *color = new_color;
- true
- } else {
- false
- }
- }
- Shape::StickyNote { background, .. } if *background != target => {
- *background = target;
- true
- }
- _ => false,
- },
- );
+ let palette = self.style.quick_colors.rendered_entries();
+ let current = self
+ .selection_primary_color()
+ .and_then(|color| color_palette_index(palette, color));
+ let offset = if direction < 0 { -1 } else { 1 };
+ let Some(next) = palette_step(palette.len(), current, offset) else {
+ self.push_toast(
+ ToastPriority::Info,
+ "selection.apply",
+ Toast::warning("The quick-color palette is empty."),
+ );
+ return false;
+ };
+ let target = palette[next].color;
- self.report_selection_apply_result(result, "color")
+ self.apply_selection_color_value_with(measurer, target)
}
}
@@ -139,6 +120,7 @@ impl InputState {
mod tests {
use super::*;
use crate::config::KeybindingsConfig;
+ use crate::draw::RED;
fn make_state() -> InputState {
let keybindings = KeybindingsConfig::default();
@@ -190,32 +172,67 @@ mod tests {
}
}
- #[test]
- fn apply_selection_color_wraps_palette_forward_from_black_to_red() {
- let measurer = TextMeasurer::default();
- let mut state = make_state();
- let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect {
+ fn add_rect(state: &mut InputState, color: Color) -> crate::draw::ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Rect {
x: 0,
y: 0,
w: 10,
h: 10,
fill: false,
- color: crate::draw::BLACK,
+ color,
thick: 2.0,
- });
+ })
+ }
+
+ fn rect_color(state: &InputState, id: crate::draw::ShapeId) -> Color {
+ match &state.boards.active_frame().shape(id).expect("rect").shape {
+ Shape::Rect { color, .. } => *color,
+ other => panic!("expected rect, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn apply_selection_color_wraps_the_quick_color_palette_forward() {
+ let measurer = TextMeasurer::default();
+ let mut state = make_state();
+ let palette = state.style.quick_colors.rendered_entries().to_vec();
+ let last = palette.last().expect("palette").color;
+ let rect_id = add_rect(&mut state, last);
state.set_selection(vec![rect_id]);
assert!(state.apply_selection_color(&measurer, 0));
- match &state
- .boards
- .active_frame()
- .shape(rect_id)
- .expect("rect")
- .shape
- {
- Shape::Rect { color, .. } => assert_eq!(*color, RED),
- other => panic!("expected rect, got {other:?}"),
- }
+ assert_eq!(rect_color(&state, rect_id), palette[0].color);
+ }
+
+ #[test]
+ fn apply_selection_color_steps_into_the_palette_from_a_custom_color() {
+ let measurer = TextMeasurer::default();
+ let mut state = make_state();
+ let palette = state.style.quick_colors.rendered_entries().to_vec();
+ let custom = Color {
+ r: 0.13,
+ g: 0.27,
+ b: 0.61,
+ a: 1.0,
+ };
+ let rect_id = add_rect(&mut state, custom);
+ state.set_selection(vec![rect_id]);
+
+ assert!(state.apply_selection_color(&measurer, 1));
+ assert_eq!(
+ rect_color(&state, rect_id),
+ palette[0].color,
+ "forward from outside the palette lands on its first swatch"
+ );
+
+ let rect_id = add_rect(&mut state, custom);
+ state.set_selection(vec![rect_id]);
+ assert!(state.apply_selection_color(&measurer, -1));
+ assert_eq!(
+ rect_color(&state, rect_id),
+ palette[palette.len() - 1].color,
+ "backward from outside the palette lands on its last swatch"
+ );
}
}
diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs
index 48a1be4f4..134200ebc 100644
--- a/src/input/state/core/properties/apply_selection/helpers.rs
+++ b/src/input/state/core/properties/apply_selection/helpers.rs
@@ -11,7 +11,7 @@ pub(in crate::input::state::core) struct SelectionApplyResult {
}
impl InputState {
- pub(super) fn selection_primary_color(&self) -> Option {
+ pub(crate) fn selection_primary_color(&self) -> Option {
let frame = self.boards.active_frame();
for id in self.selected_shape_ids() {
let Some(drawn) = frame.shape(*id) else {
diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs
index b75688bc5..b7b71764a 100644
--- a/src/input/state/core/properties/entries.rs
+++ b/src/input/state/core/properties/entries.rs
@@ -4,7 +4,7 @@ use super::summary::{
shape_color, shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background,
shape_thickness, summarize_property,
};
-use super::types::{SelectionPropertyEntry, SelectionPropertyKind};
+use super::types::{SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue};
use super::utils::{approx_eq, color_eq, color_label};
use crate::draw::{Shape, ShapeId};
use crate::input::state::{PressureThicknessEditMode, PressureThicknessEntryMode};
@@ -32,34 +32,66 @@ fn summary_value(
.unwrap_or_else(|| "Mixed".to_string())
}
+/// The summary's single editable value, or `None` when it is mixed or locked:
+/// the typed twin of [`summary_value`].
+fn summary_single(summary: &PropertySummary) -> Option {
+ if !summary.editable || summary.mixed {
+ return None;
+ }
+ summary.value.clone()
+}
+
+fn entry(
+ label: &str,
+ kind: SelectionPropertyKind,
+ summary: &PropertySummary,
+ format: impl FnOnce(T) -> String,
+ state: impl FnOnce(Option) -> SelectionPropertyValue,
+) -> SelectionPropertyEntry {
+ SelectionPropertyEntry {
+ label: label.to_string(),
+ value: summary_value(summary, format),
+ kind,
+ state: state(summary_single(summary)),
+ disabled: !summary.editable,
+ }
+}
+
impl InputState {
+ /// Whether some selected shape has a color that is not locked.
+ pub(crate) fn selection_has_editable_color(&self) -> bool {
+ let frame = self.boards.active_frame();
+ summarize_property(frame, self.selected_shape_ids(), shape_color, color_eq).editable
+ }
+
pub(super) fn build_selection_property_entries(
&self,
ids: &[ShapeId],
) -> Vec {
let frame = self.boards.active_frame();
+ let palette = self.style.quick_colors.rendered_entries();
let mut entries = Vec::new();
let color_summary = summarize_property(frame, ids, shape_color, color_eq);
if color_summary.applicable {
- let value = summary_value(&color_summary, color_label);
- entries.push(SelectionPropertyEntry {
- label: "Color".to_string(),
- value,
- kind: SelectionPropertyKind::Color,
- disabled: !color_summary.editable,
- });
+ entries.push(entry(
+ "Color",
+ SelectionPropertyKind::Color,
+ &color_summary,
+ |color| color_label(palette, color),
+ SelectionPropertyValue::Color,
+ ));
}
let thickness_summary = summarize_property(frame, ids, shape_thickness, approx_eq);
if thickness_summary.applicable {
- let value = summary_value(&thickness_summary, |v| format!("{v:.1}px"));
- entries.push(SelectionPropertyEntry {
- label: "Thickness".to_string(),
- value,
- kind: SelectionPropertyKind::Thickness,
- disabled: !thickness_summary.editable,
- });
+ entries.push(entry(
+ "Thickness",
+ SelectionPropertyKind::Thickness,
+ &thickness_summary,
+ |v| format!("{v:.1}px"),
+ SelectionPropertyValue::Number,
+ ));
} else {
let mut any_pressure = false;
let mut all_pressure = !ids.is_empty();
@@ -96,6 +128,7 @@ impl InputState {
"Locked".to_string()
},
kind: SelectionPropertyKind::Thickness,
+ state: SelectionPropertyValue::PressureVaries,
disabled: !pressure_editable,
});
}
@@ -103,98 +136,91 @@ impl InputState {
let fill_summary = summarize_property(frame, ids, shape_fill, |a, b| a == b);
if fill_summary.applicable {
- let value = summary_value(&fill_summary, |v| if v { "On" } else { "Off" }.to_string());
- entries.push(SelectionPropertyEntry {
- label: "Fill".to_string(),
- value,
- kind: SelectionPropertyKind::Fill,
- disabled: !fill_summary.editable,
- });
+ entries.push(entry(
+ "Fill",
+ SelectionPropertyKind::Fill,
+ &fill_summary,
+ |v| if v { "On" } else { "Off" }.to_string(),
+ SelectionPropertyValue::Toggle,
+ ));
}
let font_summary = summarize_property(frame, ids, shape_font_size, approx_eq);
if font_summary.applicable {
- let value = summary_value(&font_summary, |v| format!("{v:.0}pt"));
- entries.push(SelectionPropertyEntry {
- label: "Font size".to_string(),
- value,
- kind: SelectionPropertyKind::FontSize,
- disabled: !font_summary.editable,
- });
+ entries.push(entry(
+ "Font size",
+ SelectionPropertyKind::FontSize,
+ &font_summary,
+ |v| format!("{v:.0}pt"),
+ SelectionPropertyValue::Number,
+ ));
}
let head_summary = summarize_property(frame, ids, shape_arrow_head, |a, b| a == b);
if head_summary.applicable {
- let value = summary_value(&head_summary, |v| {
- if v { "End" } else { "Start" }.to_string()
- });
- entries.push(SelectionPropertyEntry {
- label: "Arrow head".to_string(),
- value,
- kind: SelectionPropertyKind::ArrowHead,
- disabled: !head_summary.editable,
- });
+ entries.push(entry(
+ "Arrow head",
+ SelectionPropertyKind::ArrowHead,
+ &head_summary,
+ |v| if v { "End" } else { "Start" }.to_string(),
+ SelectionPropertyValue::ArrowHead,
+ ));
}
let style_summary = summarize_property(frame, ids, shape_arrow_style, |a, b| a == b);
if style_summary.applicable {
- let value = summary_value(&style_summary, |v| v.label().to_string());
- entries.push(SelectionPropertyEntry {
- label: "Arrow style".to_string(),
- value,
- kind: SelectionPropertyKind::ArrowStyle,
- disabled: !style_summary.editable,
- });
+ entries.push(entry(
+ "Arrow style",
+ SelectionPropertyKind::ArrowStyle,
+ &style_summary,
+ |v| v.label().to_string(),
+ SelectionPropertyValue::ArrowStyle,
+ ));
}
let length_summary = summarize_property(frame, ids, shape_arrow_length, approx_eq);
if length_summary.applicable {
- let value = summary_value(&length_summary, |v| format!("{v:.0}px"));
- entries.push(SelectionPropertyEntry {
- label: "Arrow length".to_string(),
- value,
- kind: SelectionPropertyKind::ArrowLength,
- disabled: !length_summary.editable,
- });
+ entries.push(entry(
+ "Arrow length",
+ SelectionPropertyKind::ArrowLength,
+ &length_summary,
+ |v| format!("{v:.0}px"),
+ SelectionPropertyValue::Number,
+ ));
}
let angle_summary = summarize_property(frame, ids, shape_arrow_angle, approx_eq);
if angle_summary.applicable {
- let value = summary_value(&angle_summary, |v| format!("{v:.0} deg"));
- entries.push(SelectionPropertyEntry {
- label: "Arrow angle".to_string(),
- value,
- kind: SelectionPropertyKind::ArrowAngle,
- disabled: !angle_summary.editable,
- });
+ entries.push(entry(
+ "Arrow angle",
+ SelectionPropertyKind::ArrowAngle,
+ &angle_summary,
+ |v| format!("{v:.0}°"),
+ SelectionPropertyValue::Number,
+ ));
}
let text_bg_summary = summarize_property(frame, ids, shape_text_background, |a, b| a == b);
if text_bg_summary.applicable {
- let value = summary_value(&text_bg_summary, |v| {
- if v { "On" } else { "Off" }.to_string()
- });
- entries.push(SelectionPropertyEntry {
- label: "Text background".to_string(),
- value,
- kind: SelectionPropertyKind::TextBackground,
- disabled: !text_bg_summary.editable,
- });
+ entries.push(entry(
+ "Text background",
+ SelectionPropertyKind::TextBackground,
+ &text_bg_summary,
+ |v| if v { "On" } else { "Off" }.to_string(),
+ SelectionPropertyValue::Toggle,
+ ));
}
let spotlight_summary =
summarize_property(frame, ids, shape_spotlight_magnification, approx_eq);
if spotlight_summary.applicable {
- let value = summary_value(
+ entries.push(entry(
+ "Magnification",
+ SelectionPropertyKind::SpotlightMagnification,
&spotlight_summary,
crate::draw::format_spotlight_magnification,
- );
- entries.push(SelectionPropertyEntry {
- label: "Magnification".to_string(),
- value,
- kind: SelectionPropertyKind::SpotlightMagnification,
- disabled: !spotlight_summary.editable,
- });
+ SelectionPropertyValue::Number,
+ ));
}
entries
@@ -205,6 +231,7 @@ impl InputState {
mod tests {
use super::*;
+ use crate::domain::color::{PALETTE_GREEN, PALETTE_RED};
use crate::draw::{ArrowStyle, Color};
fn make_state() -> InputState {
@@ -311,7 +338,7 @@ mod tests {
assert_eq!(entry(&entries, "Arrow head").value, "End");
assert_eq!(entry(&entries, "Arrow length").value, "24px");
- assert_eq!(entry(&entries, "Arrow angle").value, "35 deg");
+ assert_eq!(entry(&entries, "Arrow angle").value, "35°");
}
#[test]
@@ -360,10 +387,8 @@ mod tests {
.add_shape(Shape::MarkerStroke {
points: vec![(0, 0), (10, 10)],
color: Color {
- r: 1.0,
- g: 0.0,
- b: 0.0,
a: 0.2,
+ ..PALETTE_RED
},
thick: 8.0,
});
@@ -372,4 +397,102 @@ mod tests {
assert_eq!(entry(&entries, "Color").value, "Red");
}
+
+ #[test]
+ fn property_entries_name_colors_from_the_quick_color_palette() {
+ let mut state = make_state();
+ let palette_green = add_line(&mut state, PALETTE_GREEN);
+ let pure_green = add_line(&mut state, crate::draw::GREEN);
+
+ let entries = state.build_selection_property_entries(&[palette_green]);
+ assert_eq!(entry(&entries, "Color").value, "Green");
+ assert_eq!(
+ entry(&entries, "Color").state,
+ SelectionPropertyValue::Color(Some(PALETTE_GREEN))
+ );
+
+ let entries = state.build_selection_property_entries(&[pure_green]);
+ assert_eq!(
+ entry(&entries, "Color").value,
+ "Custom",
+ "a color the palette does not hold has no palette name"
+ );
+ }
+
+ #[test]
+ fn property_entries_carry_typed_values_for_controls() {
+ let mut state = make_state();
+ let arrow_id = state.boards.active_frame_mut().add_shape(Shape::Arrow {
+ x1: 0,
+ y1: 0,
+ x2: 20,
+ y2: 10,
+ color: PALETTE_RED,
+ thick: 3.0,
+ arrow_length: 24.0,
+ arrow_angle: 35.0,
+ head_at_end: false,
+ style: ArrowStyle::Pointy,
+ bend: 0.0,
+ label: None,
+ });
+
+ let entries = state.build_selection_property_entries(&[arrow_id]);
+
+ assert_eq!(
+ entry(&entries, "Thickness").state,
+ SelectionPropertyValue::Number(Some(3.0))
+ );
+ assert_eq!(
+ entry(&entries, "Arrow head").state,
+ SelectionPropertyValue::ArrowHead(Some(false))
+ );
+ assert_eq!(
+ entry(&entries, "Arrow style").state,
+ SelectionPropertyValue::ArrowStyle(Some(ArrowStyle::Pointy))
+ );
+ assert_eq!(
+ entry(&entries, "Arrow length").state,
+ SelectionPropertyValue::Number(Some(24.0))
+ );
+ }
+
+ #[test]
+ fn mixed_and_locked_properties_carry_no_typed_value() {
+ let mut state = make_state();
+ let first = add_line(&mut state, PALETTE_RED);
+ let second = add_line(&mut state, PALETTE_GREEN);
+
+ let entries = state.build_selection_property_entries(&[first, second]);
+ assert_eq!(
+ entry(&entries, "Color").state,
+ SelectionPropertyValue::Color(None)
+ );
+
+ for id in [first, second] {
+ let index = state
+ .boards
+ .active_frame()
+ .find_index(id)
+ .expect("line index");
+ state.boards.active_frame_mut().shapes[index].locked = true;
+ }
+ let entries = state.build_selection_property_entries(&[first, second]);
+ assert_eq!(
+ entry(&entries, "Thickness").state,
+ SelectionPropertyValue::Number(None)
+ );
+ assert!(entry(&entries, "Thickness").disabled);
+ }
+
+ fn add_line(state: &mut InputState, color: Color) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Line {
+ x1: 0,
+ y1: 0,
+ x2: 40,
+ y2: 10,
+ color,
+ thick: 3.0,
+ })
+ }
}
diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs
new file mode 100644
index 000000000..e81d2f156
--- /dev/null
+++ b/src/input/state/core/properties/metrics.rs
@@ -0,0 +1,96 @@
+//! Sizes shared by the properties panel's layout, hit-testing, and painting,
+//! so what the pointer hits is exactly what the renderer draws.
+
+pub(crate) const TITLE_FONT: f64 = 15.0;
+pub(crate) const SUBTITLE_FONT: f64 = 12.0;
+pub(crate) const BODY_FONT: f64 = 13.0;
+/// Readouts beside a control: a switch's "On", a stepper's value.
+pub(crate) const VALUE_FONT: f64 = 12.5;
+/// Style names under the drawn arrow previews.
+pub(crate) const CAPTION_FONT: f64 = 10.5;
+pub(crate) const TOOLTIP_FONT: f64 = 12.0;
+/// The keyboard hint strip at the bottom.
+pub(crate) const FOOTER_FONT: f64 = 11.0;
+
+pub(crate) const MIN_WIDTH: f64 = 272.0;
+pub(crate) const PADDING_X: f64 = 16.0;
+pub(crate) const PADDING_TOP: f64 = 12.0;
+pub(crate) const PADDING_BOTTOM: f64 = 8.0;
+/// Title baseline to subtitle baseline.
+pub(crate) const SUBTITLE_STEP: f64 = 18.0;
+/// Below the header text, before the divider.
+pub(crate) const HEADER_GAP: f64 = 12.0;
+/// Divider to the first row.
+pub(crate) const ROWS_GAP: f64 = 6.0;
+pub(crate) const COLUMN_GAP: f64 = 16.0;
+/// Between columns of rows, when a tall selection needs more than one. Twice
+/// the room a row's hover fill reaches past its content, so neighboring
+/// columns' fills meet without overlapping.
+pub(crate) const COLUMN_SPACING: f64 = 2.0 * (PADDING_X - ROW_INSET);
+/// How far a row's hover fill sits inside the panel edge.
+pub(crate) const ROW_INSET: f64 = 4.0;
+
+/// A row whose control sits beside its label.
+pub(crate) const ROW_HEIGHT: f64 = 34.0;
+/// A row whose control sits under its label: space above the label, the label
+/// line, the gap before the control, and space after it.
+pub(crate) const BLOCK_TOP: f64 = 8.0;
+pub(crate) const BLOCK_LABEL_LINE: f64 = 16.0;
+pub(crate) const BLOCK_GAP: f64 = 8.0;
+pub(crate) const BLOCK_BOTTOM: f64 = 10.0;
+
+pub(crate) const SWATCH_SIZE: f64 = 20.0;
+pub(crate) const SWATCH_GAP: f64 = 7.0;
+pub(crate) const SWATCH_LINE_GAP: f64 = 8.0;
+/// Swatches plus the trailing "more colors" button on one line.
+pub(crate) const SWATCH_ITEMS_PER_LINE: usize = 9;
+/// Most quick colors the panel shows; the full picker covers the rest.
+pub(crate) const MAX_SWATCHES: usize = 17;
+
+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;
+
+pub(crate) const SWITCH_WIDTH: f64 = 34.0;
+pub(crate) const SWITCH_HEIGHT: f64 = 20.0;
+/// Between a switch and the readout to its left.
+pub(crate) const SWITCH_VALUE_GAP: f64 = 8.0;
+
+pub(crate) const SEGMENT_HEIGHT: f64 = 22.0;
+pub(crate) const SEGMENT_PAD: f64 = 2.0;
+pub(crate) const SEGMENT_ICON_WIDTH: f64 = 14.0;
+pub(crate) const SEGMENT_ICON_GAP: f64 = 4.0;
+pub(crate) const SEGMENT_TEXT_PADDING: f64 = 8.0;
+
+pub(crate) const STYLE_BUTTON_HEIGHT: f64 = 44.0;
+pub(crate) const STYLE_BUTTON_MIN_WIDTH: f64 = 52.0;
+pub(crate) const STYLE_BUTTON_GAP: f64 = 6.0;
+
+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;
+
+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;
+
+pub(crate) const TOOLTIP_PADDING_X: f64 = 8.0;
+pub(crate) const TOOLTIP_PADDING_Y: f64 = 5.0;
+pub(crate) const TOOLTIP_GAP: f64 = 6.0;
+
+/// The panel's text style at `size`.
+pub(crate) fn text_style(
+ size: f64,
+ weight: cairo::FontWeight,
+) -> crate::ui_text::UiTextStyle<'static> {
+ crate::ui_text::UiTextStyle {
+ family: "Sans",
+ slant: cairo::FontSlant::Normal,
+ weight,
+ size,
+ }
+}
diff --git a/src/input/state/core/properties/mod.rs b/src/input/state/core/properties/mod.rs
index b6fd67618..9ba1531a6 100644
--- a/src/input/state/core/properties/mod.rs
+++ b/src/input/state/core/properties/mod.rs
@@ -1,6 +1,7 @@
mod apply;
mod apply_selection;
mod entries;
+pub(crate) mod metrics;
mod panel;
mod panel_layout;
mod state;
@@ -10,5 +11,7 @@ mod utils;
pub use state::PropertiesPanelState;
pub use types::{
- PropertiesPanelLayout, SelectionPropertyEntry, SelectionPropertyKind, ShapePropertiesPanel,
+ PanelRect, 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 17f37fb6d..403bfd98d 100644
--- a/src/input/state/core/properties/panel.rs
+++ b/src/input/state/core/properties/panel.rs
@@ -1,8 +1,31 @@
use super::super::base::InputState;
+use super::metrics::MAX_SWATCHES;
use super::panel_layout::selection_panel_anchor;
-use super::types::{PropertiesPanelLayout, SelectionPropertyEntry, ShapePropertiesPanel};
+use super::types::{
+ PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, SelectionPropertyEntry,
+ SelectionPropertyValue, ShapePropertiesPanel,
+};
use super::utils::format_timestamp;
-use crate::draw::TextMeasurer;
+use crate::draw::{Color, TextMeasurer};
+use crate::util::Rect;
+
+/// Most shape kinds the multi-selection tooltip names before it trails off.
+const MAX_DETAIL_KINDS: usize = 4;
+
+/// Everything the panel shows about the current selection, rebuilt whenever
+/// the selection or its shapes change.
+struct PanelContents {
+ title: String,
+ subtitle: Option,
+ details: Option,
+ lock: PropertiesPanelLock,
+ anchor: (f64, f64),
+ anchor_rect: Option,
+ entries: Vec,
+ swatches: Vec,
+ preview_color: Option,
+ multiple_selection: bool,
+}
impl InputState {
pub fn properties_panel(&self) -> Option<&ShapePropertiesPanel> {
@@ -37,6 +60,18 @@ impl InputState {
}
}
+ /// Forgets the pointer's hover and press, for when another surface opens
+ /// over the panel and takes the pointer.
+ pub(crate) fn clear_properties_panel_pointer_state(&mut self) {
+ if let Some(panel) = self.properties.panel.as_mut() {
+ panel.hover = None;
+ panel.pressed = None;
+ }
+ if let Some(layout) = self.properties.layout.as_mut() {
+ layout.tooltip = None;
+ }
+ }
+
pub(super) fn set_properties_panel(&mut self, panel: ShapePropertiesPanel) {
self.properties.open(panel);
self.dirty_tracker.mark_full();
@@ -50,178 +85,174 @@ impl InputState {
self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::PropertiesPanel);
- let panel = (|| {
- let ids = self.selected_shape_ids();
- let frame = self.boards.active_frame();
- let canvas_bounds = self.selection_bounding_box_with(measurer, ids);
- let anchor_rect = self.selection_screen_bounding_box_with(measurer, ids);
- let anchor = selection_panel_anchor(anchor_rect, self.pointer.screen());
- let entries = self.build_selection_property_entries(ids);
-
- if ids.len() > 1 {
- let total = ids.len();
- let locked = ids
- .iter()
- .filter(|id| frame.shape(**id).map(|shape| shape.locked).unwrap_or(false))
- .count();
- let mut lines = Vec::new();
- lines.push(format!("Shapes selected: {total}"));
- if locked > 0 {
- lines.push(format!("Locked: {locked}/{total}"));
- }
- if let Some(bounds) = canvas_bounds {
- lines.push(format!(
- "Bounds: {}×{} px",
- bounds.width.max(0),
- bounds.height.max(0)
- ));
- }
- return Some(ShapePropertiesPanel {
- title: "Selection Properties".to_string(),
- anchor,
- anchor_rect,
- lines,
- entries,
- hover_index: None,
- keyboard_focus: None,
- multiple_selection: true,
- });
- }
-
- let shape_id = *ids.first()?;
- let index = frame.find_index(shape_id)?;
- let drawn = frame.shape(shape_id)?;
-
- let mut lines = Vec::new();
- lines.push(format!("Shape ID: {shape_id}"));
- lines.push(format!("Type: {}", drawn.shape.kind_name()));
- lines.push(format!("Layer: {} of {}", index + 1, frame.shapes.len()));
- lines.push(format!(
- "Locked: {}",
- if drawn.locked { "Yes" } else { "No" }
- ));
- if let Some(timestamp) = format_timestamp(drawn.created_at) {
- lines.push(format!("Created: {timestamp}"));
- }
- if let Some(bounds) = drawn.bounding_box_with(measurer) {
- lines.push(format!("Bounds: {}×{} px", bounds.width, bounds.height));
- }
-
- Some(ShapePropertiesPanel {
- title: "Shape Properties".to_string(),
- anchor,
- anchor_rect,
- lines,
- entries,
- hover_index: None,
- keyboard_focus: None,
- multiple_selection: false,
- })
- })();
-
- let Some(panel) = panel else {
+ let Some(contents) = self.properties_panel_contents(measurer) else {
return false;
};
- self.set_properties_panel(panel);
+
+ self.set_properties_panel(ShapePropertiesPanel {
+ title: contents.title,
+ subtitle: contents.subtitle,
+ details: contents.details,
+ lock: contents.lock,
+ anchor: contents.anchor,
+ anchor_rect: contents.anchor_rect,
+ entries: contents.entries,
+ swatches: contents.swatches,
+ preview_color: contents.preview_color,
+ hover: None,
+ pressed: None,
+ keyboard_focus: None,
+ focus_visible: false,
+ multiple_selection: contents.multiple_selection,
+ });
true
}
pub(super) fn refresh_properties_panel_with(&mut self, measurer: &TextMeasurer) {
self.properties.begin_refresh();
- let update = (|| {
- let ids = self.selected_shape_ids();
- if ids.is_empty() {
- return None;
- }
- let entries = self.build_selection_property_entries(ids);
- let frame = self.boards.active_frame();
- let canvas_bounds = self.selection_bounding_box_with(measurer, ids);
- let anchor_rect = self.selection_screen_bounding_box_with(measurer, ids);
- let anchor = selection_panel_anchor(anchor_rect, self.pointer.screen());
-
- let (title, lines, multiple_selection) = if ids.len() > 1 {
- let total = ids.len();
- let locked = ids
- .iter()
- .filter(|id| frame.shape(**id).map(|shape| shape.locked).unwrap_or(false))
- .count();
- let mut lines = Vec::new();
- lines.push(format!("Shapes selected: {total}"));
- if locked > 0 {
- lines.push(format!("Locked: {locked}/{total}"));
- }
- if let Some(bounds) = canvas_bounds {
- lines.push(format!(
- "Bounds: {}×{} px",
- bounds.width.max(0),
- bounds.height.max(0)
- ));
- }
- ("Selection Properties".to_string(), lines, true)
- } else {
- let shape_id = *ids.first()?;
- let index = frame.find_index(shape_id)?;
- let drawn = frame.shape(shape_id)?;
- let mut lines = Vec::new();
- lines.push(format!("Shape ID: {shape_id}"));
- lines.push(format!("Type: {}", drawn.shape.kind_name()));
- lines.push(format!("Layer: {} of {}", index + 1, frame.shapes.len()));
- lines.push(format!(
- "Locked: {}",
- if drawn.locked { "Yes" } else { "No" }
- ));
- if let Some(timestamp) = format_timestamp(drawn.created_at) {
- lines.push(format!("Created: {timestamp}"));
- }
- if let Some(bounds) = drawn.bounding_box_with(measurer) {
- lines.push(format!("Bounds: {}×{} px", bounds.width, bounds.height));
- }
- ("Shape Properties".to_string(), lines, false)
- };
-
- Some((
- title,
- lines,
- entries,
- anchor_rect,
- anchor,
- multiple_selection,
- ))
- })();
-
- let Some((title, lines, entries, anchor_rect, anchor, multiple_selection)) = update else {
+ let Some(contents) = self.properties_panel_contents(measurer) else {
self.close_properties_panel();
return;
};
-
let Some(panel) = self.properties.panel.as_mut() else {
return;
};
- panel.title = title;
- panel.lines = lines;
- panel.entries = entries;
- panel.anchor_rect = anchor_rect;
- panel.anchor = anchor;
- panel.multiple_selection = multiple_selection;
+
+ panel.title = contents.title;
+ panel.subtitle = contents.subtitle;
+ panel.details = contents.details;
+ panel.lock = contents.lock;
+ panel.anchor = contents.anchor;
+ panel.anchor_rect = contents.anchor_rect;
+ panel.entries = contents.entries;
+ panel.swatches = contents.swatches;
+ panel.preview_color = contents.preview_color;
+ panel.multiple_selection = contents.multiple_selection;
let valid_focus = panel
.keyboard_focus
.filter(|idx| *idx < panel.entries.len())
.filter(|idx| !panel.entries[*idx].disabled);
panel.keyboard_focus = valid_focus;
- if panel.hover_index.is_some()
- && panel
- .hover_index
- .is_some_and(|idx| idx >= panel.entries.len())
- {
- panel.hover_index = None;
+ // A row that disappeared takes its hover and press with it; the
+ // pending recalculation below re-reads the pointer either way.
+ let row_valid = |row: Option| row.is_none_or(|row| row < panel.entries.len());
+ if panel.hover.is_some_and(|hit| !row_valid(hit.row())) {
+ panel.hover = None;
+ }
+ if panel.pressed.is_some_and(|hit| !row_valid(hit.row())) {
+ panel.pressed = None;
}
self.properties.request_hover_recalc();
self.dirty_tracker.mark_full();
self.needs_redraw = true;
}
+
+ fn properties_panel_contents(&self, measurer: &TextMeasurer) -> Option {
+ let ids = self.selected_shape_ids();
+ if ids.is_empty() {
+ return None;
+ }
+
+ let frame = self.boards.active_frame();
+ let anchor_rect = self.selection_screen_bounding_box_with(measurer, ids);
+ let anchor = selection_panel_anchor(anchor_rect, self.pointer.screen());
+ let entries = self.build_selection_property_entries(ids);
+ let swatches = self
+ .style
+ .quick_colors
+ .rendered_entries()
+ .iter()
+ .take(MAX_SWATCHES)
+ .map(|entry| PropertiesPanelSwatch {
+ label: entry.label.clone(),
+ color: entry.color,
+ })
+ .collect();
+ let preview_color = entries.iter().find_map(|entry| match entry.state {
+ SelectionPropertyValue::Color(color) => color,
+ _ => None,
+ });
+
+ let shapes: Vec<_> = ids.iter().filter_map(|id| frame.shape(*id)).collect();
+ let locked = shapes.iter().filter(|shape| shape.locked).count();
+ let lock = if locked == 0 {
+ PropertiesPanelLock::Unlocked
+ } else if locked == shapes.len() {
+ PropertiesPanelLock::Locked
+ } else {
+ PropertiesPanelLock::Partial
+ };
+
+ let (title, subtitle, details) = if ids.len() > 1 {
+ let total = ids.len();
+ let mut subtitle = self
+ .selection_bounding_box_with(measurer, ids)
+ .map(|bounds| format!("{}×{} px", bounds.width.max(0), bounds.height.max(0)))
+ .into_iter()
+ .collect::>();
+ if locked > 0 {
+ subtitle.push(format!("{locked} of {total} locked"));
+ }
+
+ let mut kinds: Vec<&str> = Vec::new();
+ for shape in &shapes {
+ let kind = shape.shape.kind_name();
+ if !kinds.contains(&kind) {
+ kinds.push(kind);
+ }
+ }
+ let mut details = kinds
+ .iter()
+ .take(MAX_DETAIL_KINDS)
+ .copied()
+ .collect::>()
+ .join(", ");
+ if kinds.len() > MAX_DETAIL_KINDS {
+ details.push_str(", …");
+ }
+
+ (
+ format!("{total} shapes"),
+ (!subtitle.is_empty()).then(|| subtitle.join(" · ")),
+ (!details.is_empty()).then_some(details),
+ )
+ } else {
+ let shape_id = *ids.first()?;
+ let index = frame.find_index(shape_id)?;
+ let drawn = frame.shape(shape_id)?;
+
+ let mut subtitle = vec![format!("Layer {} of {}", index + 1, frame.shapes.len())];
+ if let Some(bounds) = drawn.bounding_box_with(measurer) {
+ subtitle.push(format!("{}×{} px", bounds.width, bounds.height));
+ }
+ let mut details = vec![format!("Shape ID {shape_id}")];
+ if let Some(timestamp) = format_timestamp(drawn.created_at) {
+ details.push(format!("Created {timestamp}"));
+ }
+
+ (
+ drawn.shape.kind_name().to_string(),
+ Some(subtitle.join(" · ")),
+ Some(details.join(" · ")),
+ )
+ };
+
+ Some(PanelContents {
+ title,
+ subtitle,
+ details,
+ lock,
+ anchor,
+ anchor_rect,
+ entries,
+ swatches,
+ preview_color,
+ multiple_selection: ids.len() > 1,
+ })
+ }
}
#[cfg(test)]
@@ -311,13 +342,15 @@ mod tests {
assert!(state.show_properties_panel_with(&measurer));
let panel = state.properties.panel.as_mut().expect("panel");
panel.keyboard_focus = Some(99);
- panel.hover_index = Some(99);
+ panel.hover = Some(super::super::types::PropertiesPanelHit::Row(99));
+ panel.pressed = Some(super::super::types::PropertiesPanelHit::StepUp(99));
state.refresh_properties_panel_with(&measurer);
let panel = state.properties_panel().expect("panel after refresh");
assert_eq!(panel.keyboard_focus, None);
- assert_eq!(panel.hover_index, None);
+ assert_eq!(panel.hover, None);
+ assert_eq!(panel.pressed, None);
}
#[test]
@@ -333,9 +366,67 @@ mod tests {
state.refresh_properties_panel_with(&measurer);
let panel = state.properties_panel().expect("panel after refresh");
- assert_eq!(panel.title, "Selection Properties");
+ assert_eq!(panel.title, "2 shapes");
assert!(panel.multiple_selection);
- assert!(panel.lines.iter().any(|line| line == "Shapes selected: 2"));
- assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: ")));
+ let subtitle = panel.subtitle.as_deref().expect("subtitle");
+ assert!(subtitle.ends_with(" px"), "{subtitle}");
+ assert_eq!(panel.details.as_deref(), Some("Rectangle"));
+ }
+
+ #[test]
+ fn single_shape_header_names_the_type_layer_size_and_identity() {
+ let mut state = make_state();
+ let measurer = TextMeasurer::default();
+ let _below = add_rect(&mut state, 0, 0, 5, 5);
+ let shape_id = add_rect(&mut state, 10, 20, 30, 40);
+ state.set_selection(vec![shape_id]);
+
+ assert!(state.show_properties_panel_with(&measurer));
+
+ let panel = state.properties_panel().expect("panel");
+ assert_eq!(panel.title, "Rectangle");
+ let subtitle = panel.subtitle.as_deref().expect("subtitle");
+ assert!(subtitle.starts_with("Layer 2 of 2 · "), "{subtitle}");
+ assert!(subtitle.ends_with(" px"), "{subtitle}");
+ let details = panel.details.as_deref().expect("details");
+ assert!(
+ details.starts_with(&format!("Shape ID {shape_id}")),
+ "{details}"
+ );
+ assert_eq!(panel.lock, PropertiesPanelLock::Unlocked);
+ assert!(!panel.swatches.is_empty());
+ assert!(panel.swatches.len() <= MAX_SWATCHES);
+ }
+
+ #[test]
+ fn multi_selection_header_counts_locked_shapes_and_names_their_kinds() {
+ let mut state = make_state();
+ let measurer = TextMeasurer::default();
+ let first = add_rect(&mut state, 10, 10, 20, 20);
+ let second = state.boards.active_frame_mut().add_shape(Shape::Ellipse {
+ cx: 80,
+ cy: 30,
+ rx: 10,
+ ry: 10,
+ fill: false,
+ color: state.style.current_color,
+ thick: state.style.current_thickness,
+ });
+ let index = state
+ .boards
+ .active_frame()
+ .find_index(second)
+ .expect("ellipse index");
+ state.boards.active_frame_mut().shapes[index].locked = true;
+ state.set_selection(vec![first, second]);
+
+ assert!(state.show_properties_panel_with(&measurer));
+
+ let panel = state.properties_panel().expect("panel");
+ assert_eq!(panel.title, "2 shapes");
+ let subtitle = panel.subtitle.as_deref().expect("subtitle");
+ assert!(subtitle.ends_with(" · 1 of 2 locked"), "{subtitle}");
+ assert_eq!(panel.details.as_deref(), Some("Rectangle, Ellipse"));
+ assert_eq!(panel.lock, PropertiesPanelLock::Partial);
}
}
diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs
index 5ce1dc343..604d75146 100644
--- a/src/input/state/core/properties/panel_layout/focus.rs
+++ b/src/input/state/core/properties/panel_layout/focus.rs
@@ -4,6 +4,7 @@ impl InputState {
pub fn set_properties_panel_focus(&mut self, focus: Option) {
if let Some(panel) = self.properties.panel.as_mut() {
panel.keyboard_focus = focus;
+ panel.focus_visible = focus.is_some();
}
}
@@ -13,7 +14,7 @@ impl InputState {
self.properties
.panel
.as_ref()
- .and_then(|panel| panel.keyboard_focus.or(panel.hover_index))
+ .and_then(|panel| panel.keyboard_focus.or(panel.hover_index()))
}
pub(crate) fn focus_next_properties_entry(&mut self) -> bool {
@@ -115,6 +116,7 @@ impl InputState {
#[cfg(test)]
mod tests {
+ use super::super::super::types::PropertiesPanelHit;
use super::*;
use crate::config::KeybindingsConfig;
use crate::draw::Shape;
@@ -148,7 +150,7 @@ mod tests {
let mut state = make_state();
open_rect_panel(&mut state);
let panel = state.properties.panel.as_mut().expect("panel");
- panel.hover_index = Some(1);
+ panel.hover = Some(PropertiesPanelHit::Row(1));
panel.keyboard_focus = Some(0);
assert_eq!(state.current_properties_focus_or_hover(), Some(0));
@@ -193,7 +195,7 @@ mod tests {
let mut state = make_state();
open_rect_panel(&mut state);
let panel = state.properties.panel.as_mut().expect("panel");
- panel.hover_index = Some(0);
+ panel.hover = Some(PropertiesPanelHit::Row(0));
panel.keyboard_focus = None;
panel.entries[1].disabled = true;
diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs
new file mode 100644
index 000000000..625585d20
--- /dev/null
+++ b/src/input/state/core/properties/panel_layout/geometry.rs
@@ -0,0 +1,470 @@
+//! Where each part of the properties panel sits, derived from the cached
+//! layout. Hit-testing and painting both read these rectangles, so a control
+//! is clickable exactly where it is drawn.
+
+use super::super::metrics::{
+ 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,
+ PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind,
+ SelectionPropertyValue, ShapePropertiesPanel,
+};
+use super::super::utils::color_eq;
+use crate::draw::ArrowStyle;
+
+/// How a row presents its control.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RowShape {
+ /// The control sits beside the label.
+ Inline,
+ /// The control sits on its own line under the label.
+ Block,
+}
+
+fn row_shape(entry: &SelectionPropertyEntry) -> RowShape {
+ match entry.state {
+ SelectionPropertyValue::Color(_) | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block,
+ SelectionPropertyValue::Number(_)
+ | SelectionPropertyValue::PressureVaries
+ | SelectionPropertyValue::Toggle(_)
+ | SelectionPropertyValue::ArrowHead(_) => RowShape::Inline,
+ }
+}
+
+/// 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)
+}
+
+/// 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;
+ items * SWATCH_SIZE + (items - 1.0) * SWATCH_GAP
+}
+
+pub(in crate::input::state::core::properties) fn row_height(
+ entry: &SelectionPropertyEntry,
+ swatches: usize,
+) -> f64 {
+ let control_height = match entry.state {
+ SelectionPropertyValue::Color(_) => {
+ let lines = swatch_lines(swatches) as f64;
+ lines * SWATCH_SIZE + (lines - 1.0) * SWATCH_LINE_GAP
+ }
+ SelectionPropertyValue::ArrowStyle(_) => STYLE_BUTTON_HEIGHT,
+ _ => return ROW_HEIGHT,
+ };
+ BLOCK_TOP + BLOCK_LABEL_LINE + BLOCK_GAP + control_height + BLOCK_BOTTOM
+}
+
+/// Which column each row lands in and how far below the first row's top it
+/// starts. Rows fill a column until the next would pass `budget`, then start
+/// the next column; a row taller than the budget still gets one to itself.
+pub(in crate::input::state::core::properties) fn column_slots(
+ heights: impl IntoIterator- ,
+ budget: f64,
+) -> Vec<(usize, f64)> {
+ let mut column = 0;
+ let mut top = 0.0;
+ let mut slots = Vec::new();
+ for height in heights {
+ if top > 0.0 && top + height > budget + 1e-6 {
+ column += 1;
+ top = 0.0;
+ }
+ slots.push((column, top));
+ top += height;
+ }
+ slots
+}
+
+/// The smallest budget that still fits the rows in as few columns as
+/// `max_budget` allows, so the columns come out about even rather than one
+/// full column and a short one.
+pub(in crate::input::state::core::properties) fn balanced_column_budget(
+ heights: &[f64],
+ max_budget: f64,
+) -> f64 {
+ let columns = |budget: f64| {
+ column_slots(heights.iter().copied(), budget)
+ .last()
+ .map_or(0, |(column, _)| column + 1)
+ };
+ let target = columns(max_budget);
+ let mut low = heights.iter().copied().fold(0.0, f64::max);
+ let mut high = max_budget.max(low);
+ for _ in 0..32 {
+ let middle = (low + high) / 2.0;
+ if columns(middle) <= target {
+ high = middle;
+ } else {
+ low = middle;
+ }
+ }
+ 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)
+ }
+
+ pub fn contains(&self, x: f64, y: f64) -> bool {
+ self.rect().contains(x, y)
+ }
+
+ pub fn content_x(&self) -> f64 {
+ self.origin_x + self.padding_x
+ }
+
+ pub fn content_right(&self) -> f64 {
+ self.origin_x + self.width - self.padding_x
+ }
+
+ /// 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)
+ }
+
+ /// The title's ink box, which shows the shape details on hover.
+ pub fn title_rect(&self) -> PanelRect {
+ PanelRect::new(
+ self.content_x(),
+ self.title_baseline_y - TITLE_FONT,
+ self.title_width,
+ TITLE_FONT + 5.0,
+ )
+ }
+
+ /// Every row of `panel`, in column order, top to bottom.
+ pub fn rows(&self, panel: &ShapePropertiesPanel) -> Vec {
+ let swatches = panel.swatches.len();
+ let heights: Vec = panel
+ .entries
+ .iter()
+ .map(|entry| row_height(entry, swatches))
+ .collect();
+ let slots = column_slots(heights.iter().copied(), self.column_budget);
+ panel
+ .entries
+ .iter()
+ .zip(heights)
+ .zip(slots)
+ .enumerate()
+ .map(|(index, ((entry, height), (column, offset)))| {
+ let top = self.rows_top + offset;
+ self.row_geometry(index, entry, top, height, self.column_x(column), swatches)
+ })
+ .collect()
+ }
+
+ fn row_geometry(
+ &self,
+ index: usize,
+ entry: &SelectionPropertyEntry,
+ top: f64,
+ height: f64,
+ left: f64,
+ swatches: usize,
+ ) -> PropertiesRowGeometry {
+ let reach = self.padding_x - ROW_INSET;
+ let rect = PanelRect::new(left - reach, top, self.column_width + reach * 2.0, height);
+ let right = left + self.column_width;
+
+ if row_shape(entry) == RowShape::Block {
+ let control_top = top + BLOCK_TOP + BLOCK_LABEL_LINE + BLOCK_GAP;
+ let control = match entry.state {
+ SelectionPropertyValue::ArrowStyle(_) => {
+ self.arrow_style_buttons(left, control_top)
+ }
+ _ => swatch_grid(left, control_top, swatches),
+ };
+ return PropertiesRowGeometry {
+ index,
+ rect,
+ content_x: left,
+ content_right: right,
+ label_baseline_y: top + BLOCK_TOP + BLOCK_LABEL_LINE * 0.75,
+ control,
+ };
+ }
+
+ let center_y = top + height / 2.0;
+ let control = match entry.state {
+ SelectionPropertyValue::Toggle(_) => PropertiesRowControl::Toggle {
+ switch: PanelRect::new(
+ right - SWITCH_WIDTH,
+ center_y - SWITCH_HEIGHT / 2.0,
+ SWITCH_WIDTH,
+ SWITCH_HEIGHT,
+ ),
+ },
+ SelectionPropertyValue::ArrowHead(_) => {
+ let segment = self.head_segment_width;
+ let well_width = segment * 2.0 + SEGMENT_PAD * 3.0;
+ let well_height = SEGMENT_HEIGHT + SEGMENT_PAD * 2.0;
+ let well = PanelRect::new(
+ right - well_width,
+ center_y - well_height / 2.0,
+ well_width,
+ well_height,
+ );
+ let start = PanelRect::new(
+ well.x + SEGMENT_PAD,
+ well.y + SEGMENT_PAD,
+ segment,
+ SEGMENT_HEIGHT,
+ );
+ let end = PanelRect::new(
+ start.right() + SEGMENT_PAD,
+ start.y,
+ segment,
+ SEGMENT_HEIGHT,
+ );
+ PropertiesRowControl::ArrowHead { well, start, end }
+ }
+ _ => {
+ let total = STEP_BUTTON_WIDTH * 2.0 + self.stepper_value_width;
+ let x = right - total;
+ let y = center_y - STEPPER_HEIGHT / 2.0;
+ let down = PanelRect::new(x, y, STEP_BUTTON_WIDTH, STEPPER_HEIGHT);
+ 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,
+ }
+ }
+ };
+
+ PropertiesRowGeometry {
+ index,
+ rect,
+ content_x: left,
+ content_right: right,
+ label_baseline_y: center_y + BODY_FONT * 0.35,
+ control,
+ }
+ }
+
+ fn arrow_style_buttons(&self, left: f64, top: f64) -> PropertiesRowControl {
+ let count = ArrowStyle::ALL.len() as f64;
+ let width = (self.column_width - STYLE_BUTTON_GAP * (count - 1.0)) / count;
+ let buttons = ArrowStyle::ALL
+ .into_iter()
+ .enumerate()
+ .map(|(index, style)| {
+ let x = left + index as f64 * (width + STYLE_BUTTON_GAP);
+ (style, PanelRect::new(x, top, width, STYLE_BUTTON_HEIGHT))
+ })
+ .collect();
+ PropertiesRowControl::ArrowStyles { buttons }
+ }
+
+ /// The panel part at `(x, y)`, or `None` off the panel or between parts.
+ pub fn hit_at(
+ &self,
+ panel: &ShapePropertiesPanel,
+ x: f64,
+ y: f64,
+ ) -> Option {
+ if !self.contains(x, y) {
+ return None;
+ }
+ if self.lock.contains(x, y) {
+ return Some(PropertiesPanelHit::Lock);
+ }
+ if self.title_rect().contains(x, y) {
+ return Some(PropertiesPanelHit::Title);
+ }
+
+ let row = self
+ .rows(panel)
+ .into_iter()
+ .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) {
+ Some(PropertiesPanelHit::MoreColors(index))
+ } else {
+ swatches
+ .iter()
+ .position(|rect| rect.contains(x, y))
+ .map(|swatch| PropertiesPanelHit::Swatch {
+ row: index,
+ index: swatch,
+ })
+ }
+ }
+ PropertiesRowControl::Stepper { down, up, .. } => {
+ if down.contains(x, y) {
+ Some(PropertiesPanelHit::StepDown(index))
+ } else if up.contains(x, y) {
+ Some(PropertiesPanelHit::StepUp(index))
+ } else {
+ None
+ }
+ }
+ PropertiesRowControl::Toggle { switch } => switch
+ .contains(x, y)
+ .then_some(PropertiesPanelHit::Toggle(index)),
+ PropertiesRowControl::ArrowHead { start, end, .. } => {
+ if start.contains(x, y) {
+ Some(PropertiesPanelHit::ArrowHead {
+ row: index,
+ at_end: false,
+ })
+ } else if end.contains(x, y) {
+ Some(PropertiesPanelHit::ArrowHead {
+ row: index,
+ at_end: true,
+ })
+ } else {
+ None
+ }
+ }
+ PropertiesRowControl::ArrowStyles { buttons } => buttons
+ .iter()
+ .find(|(_, rect)| rect.contains(x, y))
+ .map(|(style, _)| PropertiesPanelHit::ArrowStyle {
+ row: index,
+ style: *style,
+ }),
+ };
+ Some(hit.unwrap_or(PropertiesPanelHit::Row(index)))
+ }
+
+ /// Where `hit` is drawn: its tooltip's anchor, and where a click on it
+ /// lands.
+ pub fn hit_rect(
+ &self,
+ panel: &ShapePropertiesPanel,
+ hit: PropertiesPanelHit,
+ ) -> Option {
+ match hit {
+ PropertiesPanelHit::Title => return Some(self.title_rect()),
+ PropertiesPanelHit::Lock => return Some(self.lock),
+ _ => {}
+ }
+ let row = self.rows(panel).into_iter().nth(hit.row()?)?;
+ let rect = match (hit, row.control) {
+ (PropertiesPanelHit::Row(_), _) => row.rect,
+ (
+ PropertiesPanelHit::Swatch { index, .. },
+ PropertiesRowControl::Swatches { swatches, .. },
+ ) => *swatches.get(index)?,
+ (PropertiesPanelHit::MoreColors(_), PropertiesRowControl::Swatches { more, .. }) => {
+ more
+ }
+ (PropertiesPanelHit::StepDown(_), PropertiesRowControl::Stepper { down, .. }) => down,
+ (PropertiesPanelHit::StepUp(_), PropertiesRowControl::Stepper { up, .. }) => up,
+ (PropertiesPanelHit::Toggle(_), PropertiesRowControl::Toggle { switch }) => switch,
+ (
+ PropertiesPanelHit::ArrowHead { at_end, .. },
+ PropertiesRowControl::ArrowHead { start, end, .. },
+ ) => {
+ if at_end {
+ end
+ } else {
+ start
+ }
+ }
+ (
+ PropertiesPanelHit::ArrowStyle { style, .. },
+ PropertiesRowControl::ArrowStyles { buttons },
+ ) => buttons
+ .into_iter()
+ .find(|(candidate, _)| *candidate == style)
+ .map(|(_, rect)| rect)?,
+ _ => return None,
+ };
+ Some(rect)
+ }
+}
+
+fn swatch_grid(x: f64, top: f64, swatches: usize) -> PropertiesRowControl {
+ let cell = |item: usize| {
+ let line = item / SWATCH_ITEMS_PER_LINE;
+ let column = item % SWATCH_ITEMS_PER_LINE;
+ PanelRect::new(
+ x + column as f64 * (SWATCH_SIZE + SWATCH_GAP),
+ top + line as f64 * (SWATCH_SIZE + SWATCH_LINE_GAP),
+ SWATCH_SIZE,
+ SWATCH_SIZE,
+ )
+ };
+ PropertiesRowControl::Swatches {
+ swatches: (0..swatches).map(cell).collect(),
+ more: cell(swatches),
+ }
+}
+
+impl SelectionPropertyEntry {
+ /// What a stepper shows between its buttons. A pressure stroke's width
+ /// varies along it, which a stepper readout can only say briefly.
+ pub fn stepper_text(&self) -> &str {
+ match self.state {
+ SelectionPropertyValue::PressureVaries if !self.disabled => "Varies",
+ _ => &self.value,
+ }
+ }
+}
+
+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 {
+ return None;
+ };
+ self.swatches
+ .iter()
+ .position(|swatch| color_eq(&swatch.color, &color))
+ }
+
+ /// The text shown while the pointer rests on `hit`, if it has any.
+ pub fn tooltip(&self, hit: PropertiesPanelHit) -> Option {
+ match hit {
+ PropertiesPanelHit::Title => self.details.clone(),
+ PropertiesPanelHit::Lock => Some(
+ match (self.lock, self.multiple_selection) {
+ (PropertiesPanelLock::Locked, false) => "Unlock shape",
+ (PropertiesPanelLock::Locked, true) => "Unlock shapes",
+ (PropertiesPanelLock::Partial, _) => "Lock all shapes",
+ (PropertiesPanelLock::Unlocked, false) => "Lock shape",
+ (PropertiesPanelLock::Unlocked, true) => "Lock shapes",
+ }
+ .to_string(),
+ ),
+ PropertiesPanelHit::Swatch { index, .. } => {
+ self.swatches.get(index).map(|swatch| swatch.label.clone())
+ }
+ PropertiesPanelHit::MoreColors(_) => Some("More colors…".to_string()),
+ _ => None,
+ }
+ }
+}
diff --git a/src/input/state/core/properties/panel_layout/interaction.rs b/src/input/state/core/properties/panel_layout/interaction.rs
index 4e8e8315a..742569dec 100644
--- a/src/input/state/core/properties/panel_layout/interaction.rs
+++ b/src/input/state/core/properties/panel_layout/interaction.rs
@@ -1,29 +1,41 @@
use super::super::super::base::InputState;
+use super::super::types::PropertiesPanelHit;
+use crate::draw::TextMeasurer;
impl InputState {
- pub fn properties_panel_index_at(&self, x: i32, y: i32) -> Option {
- let layout = self.properties.layout?;
+ /// The panel part under `(x, y)`, before any disabled-row filtering.
+ pub fn properties_panel_hit_at(&self, x: i32, y: i32) -> Option {
+ let layout = self.properties.layout.as_ref()?;
let panel = self.properties.panel.as_ref()?;
- if panel.entries.is_empty() {
- return None;
- }
+ layout.hit_at(panel, x as f64, y as f64)
+ }
- let local_x = x as f64 - layout.origin_x;
- let local_y = y as f64 - layout.origin_y;
- if local_x < 0.0 || local_y < 0.0 || local_x > layout.width || local_y > layout.height {
- return None;
+ /// The part under `(x, y)` that responds to the pointer. A locked row is
+ /// inert; the header stays live, which is how locked shapes get unlocked.
+ pub(crate) fn properties_panel_active_hit_at(
+ &self,
+ x: i32,
+ y: i32,
+ ) -> Option {
+ let hit = self.properties_panel_hit_at(x, y)?;
+ let panel = self.properties.panel.as_ref()?;
+ match hit.row() {
+ Some(row) if panel.entries.get(row).is_none_or(|entry| entry.disabled) => None,
+ _ => Some(hit),
}
+ }
- let row_y = y as f64 - layout.entry_start_y;
- if row_y < 0.0 {
- return None;
- }
- let index = (row_y / layout.entry_row_height).floor() as usize;
- if index >= panel.entries.len() {
- None
- } else {
- Some(index)
- }
+ /// The row under `(x, y)`, if any.
+ pub fn properties_panel_index_at(&self, x: i32, y: i32) -> Option {
+ self.properties_panel_hit_at(x, y)?.row()
+ }
+
+ /// Whether `(x, y)` is over the open panel.
+ pub fn properties_panel_contains(&self, x: i32, y: i32) -> bool {
+ self.properties
+ .layout
+ .as_ref()
+ .is_some_and(|layout| layout.contains(x as f64, y as f64))
}
pub(super) fn update_properties_panel_hover_from_pointer_internal(
@@ -32,16 +44,19 @@ impl InputState {
y: i32,
trigger_redraw: bool,
) {
- let new_hover = self.properties_panel_index_at(x, y);
+ // A color picker opened from the panel sits over it and owns the
+ // pointer, so nothing underneath may light up.
+ let new_hover = if self.is_color_picker_popup_open() {
+ None
+ } else {
+ self.properties_panel_active_hit_at(x, y)
+ };
let Some(panel) = self.properties.panel.as_mut() else {
return;
};
- let new_hover = new_hover.filter(|idx| *idx < panel.entries.len());
- let new_hover = new_hover.filter(|idx| !panel.entries[*idx].disabled);
-
- if panel.hover_index != new_hover {
- panel.hover_index = new_hover;
+ if panel.hover != new_hover {
+ panel.hover = new_hover;
if trigger_redraw {
self.dirty_tracker.mark_full();
self.needs_redraw = true;
@@ -52,4 +67,67 @@ impl InputState {
pub fn update_properties_panel_hover_from_pointer(&mut self, x: i32, y: i32) {
self.update_properties_panel_hover_from_pointer_internal(x, y, true);
}
+
+ /// 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 {
+ if !self.properties_panel_contains(x, y) {
+ return false;
+ }
+ let hit = self.properties_panel_active_hit_at(x, y);
+ if let Some(panel) = self.properties.panel.as_mut() {
+ panel.pressed = hit;
+ if let Some(row) = hit.and_then(PropertiesPanelHit::row) {
+ panel.keyboard_focus = Some(row);
+ panel.focus_visible = false;
+ }
+ }
+ true
+ }
+
+ /// A primary release at `(x, y)`. It activates the pressed part only when
+ /// the pointer is still on it, so dragging off a button cancels the
+ /// click. A release off the panel with nothing pressed closes it.
+ pub(crate) fn release_properties_panel_at_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ x: i32,
+ y: i32,
+ ) {
+ let pressed = self
+ .properties
+ .panel
+ .as_mut()
+ .and_then(|panel| panel.pressed.take());
+ match pressed {
+ Some(hit) => {
+ if self.properties_panel_active_hit_at(x, y) == Some(hit) {
+ let _ = self.activate_properties_panel_hit_with(measurer, hit);
+ }
+ }
+ None if !self.properties_panel_contains(x, y) => self.close_properties_panel(),
+ None => {}
+ }
+ }
+
+ /// A wheel tick at `(x, y)`. Over a row it steps that row's property;
+ /// anywhere else on the panel it is swallowed, so it cannot fall through
+ /// to the canvas and resize the tool behind the panel. Returns whether
+ /// the panel took the tick.
+ pub(crate) fn properties_panel_wheel_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ x: i32,
+ y: i32,
+ scroll_direction: i32,
+ ) -> bool {
+ if !self.is_properties_panel_open() || !self.properties_panel_contains(x, y) {
+ return false;
+ }
+ if let Some(row) = self.properties_panel_index_at(x, y) {
+ let _ = self.step_properties_panel_row_by_wheel_with(measurer, row, scroll_direction);
+ }
+ true
+ }
}
diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs
index 5d3af7556..08d6a5c26 100644
--- a/src/input/state/core/properties/panel_layout/layout.rs
+++ b/src/input/state/core/properties/panel_layout/layout.rs
@@ -1,15 +1,134 @@
use cairo::Context as CairoContext;
+use cairo::FontWeight;
use super::super::super::base::InputState;
-use super::super::types::PropertiesPanelLayout;
-use super::{
- PANEL_ANCHOR_GAP, PANEL_BODY_FONT, PANEL_COLUMN_GAP, PANEL_INFO_OFFSET, PANEL_LINE_HEIGHT,
- PANEL_MARGIN, PANEL_PADDING_X, PANEL_PADDING_Y, PANEL_ROW_HEIGHT, PANEL_SECTION_GAP,
- PANEL_TITLE_FONT,
+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,
+ 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 crate::ui_text::{UiTextEngine, UiTextStyle};
+use super::super::types::{
+ PanelRect, PropertiesPanelLayout, SelectionPropertyValue, ShapePropertiesPanel,
+};
+use super::geometry::{
+ balanced_column_budget, column_slots, row_height, shows_thickness_preview, swatch_grid_width,
+};
+use super::{PANEL_ANCHOR_GAP, PANEL_MARGIN};
+use crate::draw::ArrowStyle;
+use crate::ui_text::UiTextEngine;
use crate::util::Rect;
+/// What the layout needs measured from the panel's text.
+struct Measured {
+ title_width: f64,
+ content_width: f64,
+ stepper_value_width: f64,
+ head_segment_width: f64,
+}
+
+fn measure_panel(
+ engine: &UiTextEngine,
+ ctx: &CairoContext,
+ panel: &ShapePropertiesPanel,
+) -> Measured {
+ let width = |size: f64, weight: FontWeight, text: &str| {
+ engine
+ .layout(ctx, text_style(size, weight), text, None)
+ .ink_extents()
+ .x_advance()
+ };
+
+ let title_width = width(TITLE_FONT, FontWeight::Bold, &panel.title);
+ let subtitle_width = panel.subtitle.as_deref().map_or(0.0, |subtitle| {
+ width(SUBTITLE_FONT, FontWeight::Normal, subtitle)
+ });
+
+ let mut label_width: f64 = 0.0;
+ let mut stepper_text_width: f64 = 0.0;
+ let mut toggle_value_width: f64 = 0.0;
+ for entry in &panel.entries {
+ label_width = label_width.max(width(BODY_FONT, FontWeight::Normal, &entry.label));
+ match entry.state {
+ SelectionPropertyValue::Number(_) | SelectionPropertyValue::PressureVaries => {
+ stepper_text_width = stepper_text_width.max(width(
+ VALUE_FONT,
+ FontWeight::Normal,
+ entry.stepper_text(),
+ ));
+ }
+ SelectionPropertyValue::Toggle(_) => {
+ toggle_value_width =
+ toggle_value_width.max(width(VALUE_FONT, FontWeight::Normal, &entry.value));
+ }
+ _ => {}
+ }
+ }
+ let head_text_width = ["Start", "End"]
+ .into_iter()
+ .map(|text| width(VALUE_FONT, FontWeight::Normal, text))
+ .fold(0.0, f64::max);
+ let empty_width = if panel.entries.is_empty() {
+ width(
+ BODY_FONT,
+ FontWeight::Normal,
+ crate::ui::theme::overlay::EMPTY_PROPERTIES,
+ )
+ } else {
+ 0.0
+ };
+
+ let stepper_value_width = (stepper_text_width + STEPPER_VALUE_PADDING)
+ .max(STEPPER_MIN_VALUE_WIDTH)
+ .ceil();
+ let head_segment_width =
+ (head_text_width + SEGMENT_ICON_WIDTH + SEGMENT_ICON_GAP + SEGMENT_TEXT_PADDING * 2.0)
+ .ceil();
+
+ let mut content_width = (title_width + COLUMN_GAP + LOCK_SIZE)
+ .max(subtitle_width)
+ .max(empty_width);
+ for entry in &panel.entries {
+ let row_width = match entry.state {
+ SelectionPropertyValue::Color(_) => {
+ label_width.max(swatch_grid_width(panel.swatches.len()))
+ }
+ SelectionPropertyValue::ArrowStyle(_) => {
+ 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::PressureVaries => {
+ label_width + COLUMN_GAP + STEP_BUTTON_WIDTH * 2.0 + stepper_value_width
+ }
+ SelectionPropertyValue::Toggle(_) => {
+ label_width + COLUMN_GAP + toggle_value_width + SWITCH_VALUE_GAP + SWITCH_WIDTH
+ }
+ SelectionPropertyValue::ArrowHead(_) => {
+ label_width + COLUMN_GAP + head_segment_width * 2.0 + SEGMENT_PAD * 3.0
+ }
+ };
+ content_width = content_width.max(row_width);
+ }
+
+ Measured {
+ title_width,
+ content_width,
+ stepper_value_width,
+ head_segment_width,
+ }
+}
+
impl InputState {
pub fn clear_properties_panel_layout(&mut self) {
self.properties.clear_layout();
@@ -46,175 +165,85 @@ impl InputState {
return;
};
- let mut max_line_width: f64 = 0.0;
- let mut body_ascent: f64 = 0.0;
- let mut max_label_width: f64 = 0.0;
- let mut max_value_width: f64 = 0.0;
-
let _ = ctx.save();
- let title_style = UiTextStyle {
- family: "Sans",
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Bold,
- size: PANEL_TITLE_FONT,
- };
- let body_style = UiTextStyle {
- family: "Sans",
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Normal,
- size: PANEL_BODY_FONT,
- };
- let extents = engine
- .layout(ctx, title_style, &panel.title, None)
- .ink_extents();
- max_line_width = max_line_width.max(extents.width());
-
- for line in &panel.lines {
- let extents = engine.layout(ctx, body_style, line, None).ink_extents();
- max_line_width = max_line_width.max(extents.width());
- body_ascent = body_ascent.max(-extents.y_bearing());
- }
- for entry in &panel.entries {
- let extents = engine
- .layout(ctx, body_style, &entry.label, None)
- .ink_extents();
- max_label_width = max_label_width.max(extents.width());
- let extents = engine
- .layout(ctx, body_style, &entry.value, None)
- .ink_extents();
- max_value_width = max_value_width.max(extents.width());
- }
+ let measured = measure_panel(engine, ctx, panel);
let _ = ctx.restore();
- let entries_width = if panel.entries.is_empty() {
- 0.0
+ let column_width = (measured.content_width + PADDING_X * 2.0)
+ .max(MIN_WIDTH)
+ .ceil()
+ - PADDING_X * 2.0;
+ let title_baseline = PADDING_TOP + TITLE_FONT;
+ let last_baseline = if panel.subtitle.is_some() {
+ title_baseline + SUBTITLE_STEP
} else {
- max_label_width + PANEL_COLUMN_GAP + max_value_width
+ title_baseline
};
- let panel_width = (max_line_width.max(entries_width) + PANEL_PADDING_X * 2.0).ceil();
+ let divider = last_baseline + HEADER_GAP;
+ let rows_top = divider + ROWS_GAP;
+ let heights: Vec = panel
+ .entries
+ .iter()
+ .map(|entry| row_height(entry, panel.swatches.len()))
+ .collect();
+ let fit = fit_rows(&heights, rows_top, screen_height as f64);
- let title_height = PANEL_TITLE_FONT + 4.0;
- let info_height = if panel.lines.is_empty() {
- 0.0
+ let columns = fit.columns as f64;
+ let panel_width =
+ PADDING_X * 2.0 + columns * column_width + (columns - 1.0) * COLUMN_SPACING;
+ let tail = if panel.entries.is_empty() {
+ EMPTY_HEIGHT
+ } else if fit.footer {
+ FOOTER_HEIGHT
} else {
- PANEL_INFO_OFFSET + PANEL_LINE_HEIGHT * panel.lines.len() as f64
- };
- let entries_height = if panel.entries.is_empty() {
0.0
- } else {
- PANEL_SECTION_GAP + PANEL_ROW_HEIGHT * panel.entries.len() as f64
};
- let panel_height =
- (PANEL_PADDING_Y * 2.0 + title_height + info_height + entries_height).ceil();
+ let panel_height = (rows_top + fit.height + tail + PADDING_BOTTOM).ceil();
let screen_w = screen_width as f64;
let screen_h = screen_height as f64;
+ let (origin_x, origin_y) = place_panel(
+ panel.anchor,
+ panel.anchor_rect,
+ panel_width,
+ panel_height,
+ screen_w,
+ screen_h,
+ );
- let (mut origin_x, mut origin_y) = if screen_w > 0.0 && screen_h > 0.0 {
- if let Some(bounds) = panel.anchor_rect {
- let rect_x = bounds.x as f64;
- let rect_y = bounds.y as f64;
- let rect_w = bounds.width.max(1) as f64;
- let rect_h = bounds.height.max(1) as f64;
- let center_x = rect_x + rect_w / 2.0;
- let center_y = rect_y + rect_h / 2.0;
-
- let candidates = [
- (
- rect_x + rect_w + PANEL_ANCHOR_GAP,
- center_y - panel_height / 2.0,
- ),
- (
- rect_x - panel_width - PANEL_ANCHOR_GAP,
- center_y - panel_height / 2.0,
- ),
- (
- center_x - panel_width / 2.0,
- rect_y + rect_h + PANEL_ANCHOR_GAP,
- ),
- (
- center_x - panel_width / 2.0,
- rect_y - panel_height - PANEL_ANCHOR_GAP,
- ),
- ];
-
- let max_x = screen_w - PANEL_MARGIN;
- let max_y = screen_h - PANEL_MARGIN;
- let overflow = |x: f64, y: f64| -> f64 {
- let mut overflow = 0.0;
- if x < PANEL_MARGIN {
- overflow += PANEL_MARGIN - x;
- }
- if y < PANEL_MARGIN {
- overflow += PANEL_MARGIN - y;
- }
- if x + panel_width > max_x {
- overflow += x + panel_width - max_x;
- }
- if y + panel_height > max_y {
- overflow += y + panel_height - max_y;
- }
- overflow
- };
-
- let mut best = candidates[0];
- let mut best_overflow = overflow(best.0, best.1);
- for (x, y) in candidates.into_iter().skip(1) {
- let candidate_overflow = overflow(x, y);
- if candidate_overflow < best_overflow {
- best = (x, y);
- best_overflow = candidate_overflow;
- }
- }
- best
- } else {
- panel.anchor
- }
- } else {
- panel.anchor
- };
- if origin_x + panel_width > screen_w - PANEL_MARGIN {
- origin_x = (screen_w - panel_width - PANEL_MARGIN).max(PANEL_MARGIN);
- }
- if origin_y + panel_height > screen_h - PANEL_MARGIN {
- origin_y = (screen_h - panel_height - PANEL_MARGIN).max(PANEL_MARGIN);
- }
- if origin_x < PANEL_MARGIN {
- origin_x = PANEL_MARGIN;
- }
- if origin_y < PANEL_MARGIN {
- origin_y = PANEL_MARGIN;
- }
-
- let title_baseline_y = origin_y + PANEL_PADDING_Y + PANEL_TITLE_FONT;
- // The info offset is a gap below the title box, not a baseline offset.
- let divider_y = origin_y + PANEL_PADDING_Y + title_height;
- let info_start_y = divider_y + PANEL_INFO_OFFSET + body_ascent;
- let mut entry_start_y = origin_y + PANEL_PADDING_Y + title_height + info_height;
- if !panel.entries.is_empty() {
- entry_start_y += PANEL_SECTION_GAP;
- }
-
- let label_x = origin_x + PANEL_PADDING_X;
- let value_x = origin_x + panel_width - PANEL_PADDING_X - max_value_width;
-
+ let has_footer = !panel.entries.is_empty() && fit.footer;
+ let has_subtitle = panel.subtitle.is_some();
self.properties.layout = Some(PropertiesPanelLayout {
origin_x,
origin_y,
width: panel_width,
height: panel_height,
- title_baseline_y,
- divider_y,
- info_start_y,
- entry_start_y,
- entry_row_height: PANEL_ROW_HEIGHT,
- padding_x: PANEL_PADDING_X,
- label_x,
- value_x,
+ padding_x: PADDING_X,
+ title_baseline_y: origin_y + title_baseline,
+ title_width: measured.title_width,
+ subtitle_baseline_y: has_subtitle.then_some(origin_y + title_baseline + SUBTITLE_STEP),
+ lock: PanelRect::new(
+ origin_x + panel_width - LOCK_INSET - LOCK_SIZE,
+ origin_y + LOCK_INSET,
+ LOCK_SIZE,
+ LOCK_SIZE,
+ ),
+ divider_y: origin_y + divider,
+ rows_top: origin_y + rows_top,
+ footer_top: has_footer.then_some(origin_y + rows_top + fit.height),
+ stepper_value_width: measured.stepper_value_width,
+ head_segment_width: measured.head_segment_width,
+ column_width,
+ column_budget: fit.budget,
+ tooltip: None,
});
if self.properties.pending_hover_recalc {
- let focus_set = panel.keyboard_focus.is_some();
+ let focus_set = self
+ .properties
+ .panel
+ .as_ref()
+ .is_some_and(|panel| panel.keyboard_focus.is_some());
if !focus_set {
let (px, py) = self.pointer.screen();
self.update_properties_panel_hover_from_pointer_internal(px, py, false);
@@ -222,155 +251,228 @@ impl InputState {
self.properties.pending_hover_recalc = false;
}
+ let tooltip = self.properties_panel_tooltip_rect(engine, ctx, screen_w, screen_h);
+ if let Some(layout) = self.properties.layout.as_mut() {
+ layout.tooltip = tooltip;
+ }
+
if let Some(layout) = self.properties.layout {
mark_properties_panel_region(self, layout);
}
}
-}
-fn mark_properties_panel_region(state: &mut InputState, layout: PropertiesPanelLayout) {
- let x = layout.origin_x.floor() as i32;
- let y = layout.origin_y.floor() as i32;
- let width = layout.width.ceil() as i32 + 2;
- let height = layout.height.ceil() as i32 + 2;
- let width = width.max(1);
- let height = height.max(1);
+ /// Where the hovered part's tooltip goes: centered under the part, or
+ /// above it when there is no room below, and always on screen. The
+ /// header's tooltips go above it instead, clear of the subtitle.
+ fn properties_panel_tooltip_rect(
+ &self,
+ engine: &UiTextEngine,
+ ctx: &CairoContext,
+ screen_w: f64,
+ screen_h: f64,
+ ) -> Option {
+ let panel = self.properties.panel.as_ref()?;
+ let layout = self.properties.layout.as_ref()?;
+ let hit = panel.hover?;
+ let text = panel.tooltip(hit)?;
+ let anchor = layout.hit_rect(panel, hit)?;
- if let Some(rect) = Rect::new(x, y, width, height) {
- state.dirty_tracker.mark_rect(rect);
- } else {
- state.dirty_tracker.mark_full();
+ let _ = ctx.save();
+ let text_width = engine
+ .layout(
+ ctx,
+ text_style(TOOLTIP_FONT, FontWeight::Normal),
+ &text,
+ None,
+ )
+ .ink_extents()
+ .x_advance();
+ let _ = ctx.restore();
+
+ let width = (text_width + TOOLTIP_PADDING_X * 2.0).ceil();
+ let height = (TOOLTIP_FONT + 4.0 + TOOLTIP_PADDING_Y * 2.0).ceil();
+ let (center_x, _) = anchor.center();
+ let max_x = (screen_w - PANEL_MARGIN - width).max(PANEL_MARGIN);
+ 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 fits_below = below + height <= screen_h - PANEL_MARGIN;
+ let fits_above = above >= PANEL_MARGIN;
+ let y = if (header && fits_above) || !fits_below {
+ above.max(PANEL_MARGIN)
+ } else {
+ below
+ };
+ Some(PanelRect::new(x, y, width, height))
}
}
-#[cfg(test)]
-mod engine_tests {
- use super::*;
- use crate::draw::{Shape, TextMeasurer};
+/// How the rows fit the screen's height.
+struct RowFit {
+ footer: bool,
+ columns: usize,
+ /// The column budget the geometry fills rows against.
+ budget: f64,
+ /// Height of the tallest column.
+ height: f64,
+}
- fn paint(engine: &UiTextEngine, state: &InputState) -> Vec {
- let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap();
- {
- let ctx = cairo::Context::new(&surface).unwrap();
- crate::ui::render_properties_panel_with_engine(engine, &ctx, state, 800, 600);
- }
- surface.data().unwrap().to_vec()
+/// Fits the rows under a header of `rows_top` into `screen_h`: in one column
+/// with the keyboard hints when they fit, then without the hints, and
+/// otherwise in as few even columns as fit. Every row stays on screen and
+/// the wheel keeps stepping the row under it, which scrolling would take away.
+fn fit_rows(heights: &[f64], rows_top: f64, screen_h: f64) -> RowFit {
+ let total: f64 = heights.iter().sum();
+ let available = if screen_h > 0.0 {
+ screen_h - PANEL_MARGIN * 2.0
+ } else {
+ f64::INFINITY
+ };
+ let single = |footer: bool| RowFit {
+ footer,
+ columns: 1,
+ budget: f64::INFINITY,
+ height: total,
+ };
+
+ if rows_top + total + FOOTER_HEIGHT + PADDING_BOTTOM <= available {
+ return single(true);
+ }
+ if rows_top + total + PADDING_BOTTOM <= available {
+ return single(false);
}
- #[test]
- fn info_text_clears_divider_and_entries_without_growing_panel() {
- let engine = UiTextEngine::default();
- let mut state = crate::input::state::test_support::make_test_input_state();
- let id = state.boards.active_frame_mut().add_shape(Shape::Text {
- x: 40,
- y: 60,
- text: "Shape info".into(),
- color: crate::draw::Color::new(1.0, 0.0, 0.0, 1.0),
- size: 18.0,
- font_descriptor: Default::default(),
- background_enabled: false,
- wrap_width: None,
- });
- state.set_selection(vec![id]);
- assert!(state.show_properties_panel_with(&TextMeasurer::default()));
- let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap();
- let ctx = cairo::Context::new(&surface).unwrap();
- state.update_properties_panel_layout(&ctx, 800, 600);
- let panel = state.properties_panel().unwrap();
- let layout = state.properties_panel_layout().unwrap();
- assert!(!panel.lines.is_empty());
- assert!(!panel.entries.is_empty());
- let style = UiTextStyle {
- family: "Sans",
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Normal,
- size: PANEL_BODY_FONT,
- };
- for (index, line) in panel.lines.iter().enumerate() {
- let ink = engine.layout(&ctx, style, line, None).ink_extents();
- let top = layout.info_start_y + index as f64 * PANEL_LINE_HEIGHT + ink.y_bearing();
- assert!(top > layout.divider_y, "info ink must clear the divider");
- assert!(
- top + ink.height() < layout.entry_start_y,
- "info ink must clear entries"
- );
+ let budget = balanced_column_budget(heights, available - rows_top - PADDING_BOTTOM);
+ let mut column_heights: Vec = Vec::new();
+ for ((column, offset), height) in column_slots(heights.iter().copied(), budget)
+ .into_iter()
+ .zip(heights)
+ {
+ if column_heights.len() <= column {
+ column_heights.push(0.0);
}
- let expected_height = PANEL_PADDING_Y * 2.0
- + PANEL_TITLE_FONT
- + 4.0
- + PANEL_INFO_OFFSET
- + PANEL_LINE_HEIGHT * panel.lines.len() as f64
- + PANEL_SECTION_GAP
- + PANEL_ROW_HEIGHT * panel.entries.len() as f64;
- assert_eq!(layout.height, expected_height.ceil());
- let rows_bottom =
- layout.entry_start_y + panel.entries.len() as f64 * layout.entry_row_height;
- assert_eq!(
- layout.origin_y + layout.height - rows_bottom,
- PANEL_PADDING_Y
- );
+ column_heights[column] = offset + height;
}
+ RowFit {
+ footer: false,
+ columns: column_heights.len().max(1),
+ budget,
+ height: column_heights.iter().copied().fold(0.0, f64::max),
+ }
+}
- #[test]
- fn deferred_property_refresh_uses_both_owners_before_layout_and_paint() {
- let engine = UiTextEngine::default();
- let measurer = TextMeasurer::default();
- let mut state = crate::input::state::test_support::make_test_input_state();
- let id = state.boards.active_frame_mut().add_shape(Shape::Text {
- x: 40,
- y: 60,
- text: "你好 initial".into(),
- color: crate::draw::Color::new(1.0, 0.0, 0.0, 1.0),
- size: 18.0,
- font_descriptor: Default::default(),
- background_enabled: false,
- wrap_width: None,
- });
- state.set_selection(vec![id]);
- assert!(state.show_properties_panel_with(&measurer));
- let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap();
- let ctx = cairo::Context::new(&surface).unwrap();
- state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600);
- let old = state.properties_panel().unwrap().lines.clone();
- {
- let frame = state.boards.active_frame_mut();
- let shape = frame.shape_mut(id).unwrap();
- if let Shape::Text { text, .. } = &mut shape.shape {
- *text = "A substantially wider changed text label".into();
+/// Picks the panel's top-left corner: beside the selection on whichever side
+/// leaves the most of it on screen, then clamped inside the screen margin.
+fn place_panel(
+ anchor: (f64, f64),
+ anchor_rect: Option,
+ panel_width: f64,
+ panel_height: f64,
+ screen_w: f64,
+ screen_h: f64,
+) -> (f64, f64) {
+ let (mut origin_x, mut origin_y) = if screen_w > 0.0 && screen_h > 0.0 {
+ if let Some(bounds) = anchor_rect {
+ let rect_x = bounds.x as f64;
+ let rect_y = bounds.y as f64;
+ let rect_w = bounds.width.max(1) as f64;
+ let rect_h = bounds.height.max(1) as f64;
+ let center_x = rect_x + rect_w / 2.0;
+ let center_y = rect_y + rect_h / 2.0;
+
+ let candidates = [
+ (
+ rect_x + rect_w + PANEL_ANCHOR_GAP,
+ center_y - panel_height / 2.0,
+ ),
+ (
+ rect_x - panel_width - PANEL_ANCHOR_GAP,
+ center_y - panel_height / 2.0,
+ ),
+ (
+ center_x - panel_width / 2.0,
+ rect_y + rect_h + PANEL_ANCHOR_GAP,
+ ),
+ (
+ center_x - panel_width / 2.0,
+ rect_y - panel_height - PANEL_ANCHOR_GAP,
+ ),
+ ];
+
+ let max_x = screen_w - PANEL_MARGIN;
+ let max_y = screen_h - PANEL_MARGIN;
+ let overflow = |x: f64, y: f64| -> f64 {
+ let mut overflow = 0.0;
+ if x < PANEL_MARGIN {
+ overflow += PANEL_MARGIN - x;
+ }
+ if y < PANEL_MARGIN {
+ overflow += PANEL_MARGIN - y;
+ }
+ if x + panel_width > max_x {
+ overflow += x + panel_width - max_x;
+ }
+ if y + panel_height > max_y {
+ overflow += y + panel_height - max_y;
+ }
+ overflow
+ };
+
+ let mut best = candidates[0];
+ let mut best_overflow = overflow(best.0, best.1);
+ for (x, y) in candidates.into_iter().skip(1) {
+ let candidate_overflow = overflow(x, y);
+ if candidate_overflow < best_overflow {
+ best = (x, y);
+ best_overflow = candidate_overflow;
+ }
}
- shape.invalidate_bounds();
+ best
+ } else {
+ anchor
}
- state.properties.mark_needs_refresh();
- state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600);
- assert!(!state.properties.needs_refresh());
- assert_ne!(
- state.properties_panel().unwrap().lines,
- old,
- "deferred refresh updates measured shape summary"
- );
- let layout = *state.properties_panel_layout().unwrap();
- let actual = paint(&engine, &state);
- assert!(actual.iter().any(|&byte| byte != 0));
- state.update_properties_panel_layout(&ctx, 800, 600);
- let fresh = state.properties_panel_layout().unwrap();
- assert_eq!(
- (fresh.origin_x, fresh.origin_y, fresh.width, fresh.height),
- (
- layout.origin_x,
- layout.origin_y,
- layout.width,
- layout.height
+ } else {
+ anchor
+ };
+ if origin_x + panel_width > screen_w - PANEL_MARGIN {
+ origin_x = (screen_w - panel_width - PANEL_MARGIN).max(PANEL_MARGIN);
+ }
+ if origin_y + panel_height > screen_h - PANEL_MARGIN {
+ origin_y = (screen_h - panel_height - PANEL_MARGIN).max(PANEL_MARGIN);
+ }
+ if origin_x < PANEL_MARGIN {
+ origin_x = PANEL_MARGIN;
+ }
+ if origin_y < PANEL_MARGIN {
+ origin_y = PANEL_MARGIN;
+ }
+ (origin_x, origin_y)
+}
+
+fn mark_properties_panel_region(state: &mut InputState, layout: PropertiesPanelLayout) {
+ let region = [Some(layout.rect()), layout.tooltip]
+ .into_iter()
+ .flatten()
+ .reduce(|a, b| {
+ let x = a.x.min(b.x);
+ let y = a.y.min(b.y);
+ PanelRect::new(
+ x,
+ y,
+ a.right().max(b.right()) - x,
+ a.bottom().max(b.bottom()) - y,
)
- );
- assert_eq!(
- (fresh.label_x, fresh.value_x, fresh.entry_start_y),
- (layout.label_x, layout.value_x, layout.entry_start_y)
- );
- assert!(actual == paint(&UiTextEngine::default(), &state));
- state.selection_interaction.set(Vec::new());
- state.properties.mark_needs_refresh();
- state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600);
- assert!(state.properties_panel().is_none());
- assert!(state.properties_panel_layout().is_none());
+ })
+ .unwrap_or_else(|| layout.rect());
+ let x = region.x.floor() as i32;
+ let y = region.y.floor() as i32;
+ let width = (region.width.ceil() as i32 + 2).max(1);
+ let height = (region.height.ceil() as i32 + 2).max(1);
+
+ if let Some(rect) = Rect::new(x, y, width, height) {
+ state.dirty_tracker.mark_rect(rect);
+ } else {
+ state.dirty_tracker.mark_full();
}
}
diff --git a/src/input/state/core/properties/panel_layout/mod.rs b/src/input/state/core/properties/panel_layout/mod.rs
index 346df7fa5..9fe92971d 100644
--- a/src/input/state/core/properties/panel_layout/mod.rs
+++ b/src/input/state/core/properties/panel_layout/mod.rs
@@ -1,21 +1,15 @@
use crate::util::Rect;
-const PANEL_TITLE_FONT: f64 = 15.0;
-const PANEL_BODY_FONT: f64 = 13.0;
-const PANEL_LINE_HEIGHT: f64 = 18.0;
-const PANEL_ROW_HEIGHT: f64 = 22.0;
-const PANEL_PADDING_X: f64 = 16.0;
-const PANEL_PADDING_Y: f64 = 12.0;
-const PANEL_COLUMN_GAP: f64 = 16.0;
-const PANEL_SECTION_GAP: f64 = 8.0;
const PANEL_MARGIN: f64 = 12.0;
-const PANEL_INFO_OFFSET: f64 = 12.0;
const PANEL_ANCHOR_GAP: f64 = 12.0;
const PANEL_POINTER_OFFSET: f64 = 16.0;
mod focus;
+mod geometry;
mod interaction;
mod layout;
+#[cfg(test)]
+mod tests;
pub(super) fn selection_panel_anchor(bounds: Option, pointer: (i32, i32)) -> (f64, f64) {
bounds
diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs
new file mode 100644
index 000000000..7711fa224
--- /dev/null
+++ b/src/input/state/core/properties/panel_layout/tests.rs
@@ -0,0 +1,385 @@
+use super::super::metrics::{FOOTER_HEIGHT, PADDING_BOTTOM};
+use super::super::types::{
+ PanelRect, PropertiesPanelHit, PropertiesRowControl, PropertiesRowGeometry,
+};
+use crate::domain::color::PALETTE_RED;
+use crate::draw::{ArrowStyle, Shape, ShapeId, TextMeasurer};
+use crate::input::state::InputState;
+use crate::ui_text::UiTextEngine;
+
+const SCREEN: (u32, u32) = (800, 600);
+
+fn arrow(state: &mut InputState) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Arrow {
+ x1: 40,
+ y1: 60,
+ x2: 200,
+ y2: 90,
+ color: PALETTE_RED,
+ thick: 3.0,
+ arrow_length: 24.0,
+ arrow_angle: 35.0,
+ head_at_end: true,
+ style: ArrowStyle::Pointy,
+ bend: 0.0,
+ label: None,
+ })
+}
+
+fn text(state: &mut InputState, text: &str) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Text {
+ x: 40,
+ y: 60,
+ text: text.into(),
+ color: PALETTE_RED,
+ size: 18.0,
+ font_descriptor: Default::default(),
+ background_enabled: false,
+ wrap_width: None,
+ })
+}
+
+fn lay_out(state: &mut InputState, screen: (u32, u32)) {
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ state.update_properties_panel_layout(&ctx, screen.0, screen.1);
+}
+
+fn open_panel(ids: Vec, state: &mut InputState) {
+ state.set_selection(ids);
+ assert!(state.show_properties_panel_with(&TextMeasurer::default()));
+ lay_out(state, SCREEN);
+}
+
+fn rows(state: &InputState) -> Vec {
+ let layout = state.properties_panel_layout().expect("layout");
+ layout.rows(state.properties_panel().expect("panel"))
+}
+
+fn paint(state: &InputState) -> Vec {
+ let mut surface =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, SCREEN.0 as i32, SCREEN.1 as i32)
+ .unwrap();
+ {
+ let ctx = cairo::Context::new(&surface).unwrap();
+ crate::ui::render_properties_panel_with_engine(
+ &UiTextEngine::default(),
+ &ctx,
+ state,
+ SCREEN.0,
+ SCREEN.1,
+ );
+ }
+ surface.data().unwrap().to_vec()
+}
+
+fn inside(outer: PanelRect, inner: PanelRect) -> bool {
+ inner.x >= outer.x
+ && inner.y >= outer.y
+ && inner.right() <= outer.right() + 1e-9
+ && inner.bottom() <= outer.bottom() + 1e-9
+}
+
+/// Every clickable rectangle of a row, with the hit its center should give.
+fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelHit)> {
+ let index = row.index;
+ match &row.control {
+ PropertiesRowControl::Swatches { swatches, more } => swatches
+ .iter()
+ .enumerate()
+ .map(|(swatch, rect)| {
+ (
+ *rect,
+ PropertiesPanelHit::Swatch {
+ row: index,
+ index: swatch,
+ },
+ )
+ })
+ .chain([(*more, PropertiesPanelHit::MoreColors(index))])
+ .collect(),
+ PropertiesRowControl::Stepper { down, up, .. } => vec![
+ (*down, PropertiesPanelHit::StepDown(index)),
+ (*up, PropertiesPanelHit::StepUp(index)),
+ ],
+ PropertiesRowControl::Toggle { switch } => {
+ vec![(*switch, PropertiesPanelHit::Toggle(index))]
+ }
+ PropertiesRowControl::ArrowHead { start, end, .. } => vec![
+ (
+ *start,
+ PropertiesPanelHit::ArrowHead {
+ row: index,
+ at_end: false,
+ },
+ ),
+ (
+ *end,
+ PropertiesPanelHit::ArrowHead {
+ row: index,
+ at_end: true,
+ },
+ ),
+ ],
+ PropertiesRowControl::ArrowStyles { buttons } => buttons
+ .iter()
+ .map(|(style, rect)| {
+ (
+ *rect,
+ PropertiesPanelHit::ArrowStyle {
+ row: index,
+ style: *style,
+ },
+ )
+ })
+ .collect(),
+ }
+}
+
+#[test]
+fn rows_stack_from_the_divider_to_the_footer() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let id = arrow(&mut state);
+ open_panel(vec![id], &mut state);
+ let layout = *state.properties_panel_layout().expect("layout");
+ let rows = rows(&state);
+
+ assert_eq!(rows.len(), state.properties_panel().unwrap().entries.len());
+ assert!(layout.divider_y < layout.rows_top);
+ let mut top = layout.rows_top;
+ for row in &rows {
+ assert_eq!(
+ row.rect.y, top,
+ "row {} starts where the last ended",
+ row.index
+ );
+ top = row.rect.bottom();
+ }
+ let footer_top = layout.footer_top.expect("footer");
+ assert_eq!(footer_top, top);
+ assert_eq!(
+ layout.origin_y + layout.height,
+ (footer_top + FOOTER_HEIGHT + PADDING_BOTTOM).ceil()
+ );
+}
+
+#[test]
+fn every_control_is_hit_where_it_is_drawn_and_stays_in_its_row() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let arrow = arrow(&mut state);
+ let note = text(&mut state, "Label");
+ let rect = state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x: 300,
+ y: 300,
+ w: 40,
+ h: 40,
+ fill: false,
+ color: PALETTE_RED,
+ thick: 2.0,
+ });
+ open_panel(vec![arrow, note, rect], &mut state);
+ let layout = *state.properties_panel_layout().expect("layout");
+ let panel = state.properties_panel().expect("panel");
+
+ for row in rows(&state) {
+ assert!(inside(layout.rect(), row.rect), "row {} fits", row.index);
+ for (rect, expected) in control_hits(&row) {
+ assert!(inside(row.rect, rect), "{expected:?} sits in its row");
+ assert!(
+ rect.x >= row.content_x - 1e-9 && rect.right() <= row.content_right + 1e-9,
+ "{expected:?} stays in the content column"
+ );
+ let (cx, cy) = rect.center();
+ assert_eq!(layout.hit_at(panel, cx, cy), Some(expected));
+ }
+ }
+ let (lock_x, lock_y) = layout.lock.center();
+ assert_eq!(
+ layout.hit_at(panel, lock_x, lock_y),
+ Some(PropertiesPanelHit::Lock)
+ );
+ let (title_x, title_y) = layout.title_rect().center();
+ assert_eq!(
+ layout.hit_at(panel, title_x, title_y),
+ Some(PropertiesPanelHit::Title)
+ );
+ assert_eq!(layout.hit_at(panel, layout.origin_x - 5.0, title_y), None);
+}
+
+#[test]
+fn a_small_screen_keeps_the_whole_panel_on_screen() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let id = arrow(&mut state);
+ state.set_selection(vec![id]);
+ assert!(state.show_properties_panel_with(&TextMeasurer::default()));
+
+ lay_out(&mut state, (360, 560));
+
+ let layout = state.properties_panel_layout().expect("layout");
+ assert!(layout.origin_x >= 12.0 && layout.origin_y >= 12.0);
+ assert!(layout.origin_x + layout.width <= 360.0 - 12.0 + 1e-9);
+ assert!(layout.origin_y + layout.height <= 560.0 - 12.0 + 1e-9);
+}
+
+#[test]
+fn hovering_a_swatch_shows_its_name_in_a_tooltip_on_screen() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let id = arrow(&mut state);
+ open_panel(vec![id], &mut state);
+ let (swatch, expected) = rows(&state)
+ .iter()
+ .flat_map(control_hits)
+ .find(|(_, hit)| matches!(hit, PropertiesPanelHit::Swatch { index: 1, .. }))
+ .expect("second swatch");
+ let (x, y) = swatch.center();
+
+ state.update_pointer_position(x as i32, y as i32);
+ state.update_properties_panel_hover_from_pointer(x as i32, y as i32);
+ lay_out(&mut state, SCREEN);
+
+ let panel = state.properties_panel().expect("panel");
+ assert_eq!(panel.hover, Some(expected));
+ assert_eq!(
+ panel.tooltip(expected),
+ Some(panel.swatches[1].label.clone())
+ );
+ let tooltip = state
+ .properties_panel_layout()
+ .and_then(|layout| layout.tooltip)
+ .expect("tooltip");
+ assert!(
+ tooltip.y >= swatch.bottom(),
+ "the tooltip hangs below the swatch"
+ );
+ assert!(tooltip.x >= 0.0 && tooltip.right() <= SCREEN.0 as f64);
+}
+
+#[test]
+fn deferred_refresh_updates_the_header_before_layout_and_paint() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let id = text(&mut state, "你好 initial");
+ open_panel(vec![id], &mut state);
+ let before = state.properties_panel().unwrap().subtitle.clone();
+ let painted = paint(&state);
+ assert!(painted.iter().any(|&byte| byte != 0), "the panel paints");
+ assert_eq!(painted, paint(&state), "painting is deterministic");
+
+ {
+ let frame = state.boards.active_frame_mut();
+ let shape = frame.shape_mut(id).unwrap();
+ if let Shape::Text { text, .. } = &mut shape.shape {
+ *text = "A substantially wider changed text label".into();
+ }
+ shape.invalidate_bounds();
+ }
+ state.properties.mark_needs_refresh();
+ lay_out(&mut state, SCREEN);
+
+ assert!(!state.properties.needs_refresh());
+ assert_ne!(
+ state.properties_panel().unwrap().subtitle,
+ before,
+ "the size in the subtitle follows the edit"
+ );
+
+ state.selection_interaction.set(Vec::new());
+ state.properties.mark_needs_refresh();
+ lay_out(&mut state, SCREEN);
+ assert!(state.properties_panel().is_none());
+ assert!(state.properties_panel_layout().is_none());
+}
+
+fn spotlight(state: &mut InputState) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Spotlight {
+ cx: 400,
+ cy: 300,
+ rx: 40,
+ ry: 30,
+ magnification: 2.0,
+ })
+}
+
+/// Every row and control of the open panel is on a screen `height` tall and
+/// answers the pointer where it is drawn.
+fn assert_all_reachable(state: &InputState, height: f64) {
+ let layout = *state.properties_panel_layout().expect("layout");
+ let panel = state.properties_panel().expect("panel");
+ assert!(layout.origin_y >= 12.0 - 1e-9);
+ assert!(
+ layout.origin_y + layout.height <= height - 12.0 + 1e-9,
+ "panel bottom {} passes the screen ({height})",
+ layout.origin_y + layout.height
+ );
+ for row in layout.rows(panel) {
+ assert!(inside(layout.rect(), row.rect), "row {} fits", row.index);
+ for (rect, expected) in control_hits(&row) {
+ let (cx, cy) = rect.center();
+ assert_eq!(layout.hit_at(panel, cx, cy), Some(expected));
+ }
+ }
+}
+
+#[test]
+fn a_slightly_short_screen_drops_the_keyboard_hints_first() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let id = arrow(&mut state);
+ open_panel(vec![id], &mut state);
+ let full = state.properties_panel_layout().expect("layout").height;
+
+ let height = full + 24.0 - FOOTER_HEIGHT / 2.0;
+ lay_out(&mut state, (SCREEN.0, height as u32));
+
+ let layout = *state.properties_panel_layout().expect("layout");
+ assert_eq!(layout.footer_top, None, "the hints go");
+ assert_eq!(layout.column_budget, f64::INFINITY, "one column still fits");
+ assert_all_reachable(&state, height.floor());
+}
+
+#[test]
+fn a_selection_taller_than_the_screen_flows_into_columns() {
+ let mut state = crate::input::state::test_support::make_test_input_state();
+ let rect = state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x: 300,
+ y: 200,
+ w: 40,
+ h: 40,
+ fill: false,
+ color: PALETTE_RED,
+ thick: 2.0,
+ });
+ let ids = vec![
+ rect,
+ arrow(&mut state),
+ text(&mut state, "Note"),
+ spotlight(&mut state),
+ ];
+ open_panel(ids, &mut state);
+ let single = *state.properties_panel_layout().expect("layout");
+
+ lay_out(&mut state, (SCREEN.0, 480));
+
+ let layout = *state.properties_panel_layout().expect("layout");
+ let rows = rows(&state);
+ let columns: std::collections::BTreeSet =
+ 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_all_reachable(&state, 480.0);
+ let heights: Vec = columns
+ .iter()
+ .map(|x| {
+ rows.iter()
+ .filter(|row| row.content_x.to_bits() == *x)
+ .map(|row| row.rect.height)
+ .sum()
+ })
+ .collect();
+ let (short, tall) = heights
+ .iter()
+ .fold((f64::MAX, 0.0_f64), |(lo, hi), h| (lo.min(*h), hi.max(*h)));
+ assert!(
+ tall - short < 100.0,
+ "the columns come out about even: {heights:?}"
+ );
+}
diff --git a/src/input/state/core/properties/state.rs b/src/input/state/core/properties/state.rs
index 90627afaf..5a209d192 100644
--- a/src/input/state/core/properties/state.rs
+++ b/src/input/state/core/properties/state.rs
@@ -68,12 +68,18 @@ mod tests {
fn panel() -> ShapePropertiesPanel {
ShapePropertiesPanel {
title: "Properties".into(),
+ subtitle: None,
+ details: None,
+ lock: crate::input::state::PropertiesPanelLock::Unlocked,
anchor: (10.0, 20.0),
anchor_rect: None,
- lines: Vec::new(),
entries: Vec::new(),
- hover_index: None,
+ swatches: Vec::new(),
+ preview_color: None,
+ hover: None,
+ pressed: None,
keyboard_focus: None,
+ focus_visible: false,
multiple_selection: false,
}
}
diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs
index a64990807..9433714d3 100644
--- a/src/input/state/core/properties/types.rs
+++ b/src/input/state/core/properties/types.rs
@@ -1,3 +1,4 @@
+use crate::draw::{ArrowStyle, Color};
use crate::util::Rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -14,38 +15,243 @@ pub enum SelectionPropertyKind {
SpotlightMagnification,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+impl SelectionPropertyKind {
+ /// The kind's value with nothing known about it, for fixtures that only
+ /// care about an entry's text.
+ #[cfg(test)]
+ pub fn unknown_value(self) -> SelectionPropertyValue {
+ match self {
+ Self::Color => SelectionPropertyValue::Color(None),
+ Self::Fill | Self::TextBackground => SelectionPropertyValue::Toggle(None),
+ Self::ArrowHead => SelectionPropertyValue::ArrowHead(None),
+ Self::ArrowStyle => SelectionPropertyValue::ArrowStyle(None),
+ Self::Thickness
+ | Self::FontSize
+ | Self::ArrowLength
+ | Self::ArrowAngle
+ | Self::SpotlightMagnification => SelectionPropertyValue::Number(None),
+ }
+ }
+}
+
+/// A property's current value, typed, beside the text an entry displays.
+///
+/// Surfaces that draw a control instead of a label (a swatch ring, a switch
+/// position, the pressed arrow style) read this; the text stays for the ones
+/// that only show a readout. `None` inside a variant means there is no single
+/// value to show: the editable shapes disagree, or every shape is locked.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum SelectionPropertyValue {
+ Color(Option),
+ Number(Option),
+ /// A pressure stroke stores a width per point, so it has no one number.
+ PressureVaries,
+ Toggle(Option),
+ /// Whether the head sits at the end of the arrow (`true`) or its start.
+ ArrowHead(Option),
+ ArrowStyle(Option),
+}
+
+#[derive(Debug, Clone, PartialEq)]
pub struct SelectionPropertyEntry {
pub label: String,
pub value: String,
pub kind: SelectionPropertyKind,
+ pub state: SelectionPropertyValue,
pub disabled: bool,
}
+/// One quick-color swatch the properties panel offers.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PropertiesPanelSwatch {
+ pub label: String,
+ pub color: Color,
+}
+
+/// How much of the selection is locked, for the header's lock toggle.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PropertiesPanelLock {
+ Unlocked,
+ /// Some selected shapes are locked and some are not.
+ Partial,
+ Locked,
+}
+
+/// 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,
+ /// A row away from its controls.
+ Row(usize),
+ Swatch {
+ row: usize,
+ index: usize,
+ },
+ /// The swatch row's trailing button that opens the full color picker.
+ MoreColors(usize),
+ StepDown(usize),
+ StepUp(usize),
+ Toggle(usize),
+ ArrowHead {
+ row: usize,
+ at_end: bool,
+ },
+ ArrowStyle {
+ row: usize,
+ style: ArrowStyle,
+ },
+}
+
+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::Row(row)
+ | Self::Swatch { row, .. }
+ | Self::MoreColors(row)
+ | Self::StepDown(row)
+ | Self::StepUp(row)
+ | Self::Toggle(row)
+ | Self::ArrowHead { row, .. }
+ | Self::ArrowStyle { row, .. } => Some(row),
+ }
+ }
+}
+
+/// An axis-aligned rectangle in screen space.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct PanelRect {
+ pub x: f64,
+ pub y: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+impl PanelRect {
+ pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
+ Self {
+ x,
+ y,
+ width,
+ height,
+ }
+ }
+
+ pub fn contains(&self, x: f64, y: f64) -> bool {
+ x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
+ }
+
+ pub fn center(&self) -> (f64, f64) {
+ (self.x + self.width / 2.0, self.y + self.height / 2.0)
+ }
+
+ pub fn right(&self) -> f64 {
+ self.x + self.width
+ }
+
+ pub fn bottom(&self) -> f64 {
+ self.y + self.height
+ }
+}
+
+/// Where one property row's control sits.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PropertiesRowControl {
+ Swatches {
+ swatches: Vec,
+ more: PanelRect,
+ },
+ Stepper {
+ down: PanelRect,
+ value: PanelRect,
+ up: PanelRect,
+ /// A short stroke drawn at the current thickness.
+ preview: Option,
+ },
+ Toggle {
+ switch: PanelRect,
+ },
+ ArrowHead {
+ well: PanelRect,
+ start: PanelRect,
+ end: PanelRect,
+ },
+ ArrowStyles {
+ buttons: Vec<(ArrowStyle, PanelRect)>,
+ },
+}
+
+/// One property row laid out for this frame.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PropertiesRowGeometry {
+ pub index: usize,
+ pub rect: PanelRect,
+ /// The left and right edges of the row's column content.
+ pub content_x: f64,
+ pub content_right: f64,
+ pub label_baseline_y: f64,
+ pub control: PropertiesRowControl,
+}
+
#[derive(Debug, Clone, Copy)]
pub struct PropertiesPanelLayout {
pub origin_x: f64,
pub origin_y: f64,
pub width: f64,
pub height: f64,
+ pub padding_x: f64,
pub title_baseline_y: f64,
+ pub title_width: f64,
+ pub subtitle_baseline_y: Option,
+ pub lock: PanelRect,
pub divider_y: f64,
- pub info_start_y: f64,
- pub entry_start_y: f64,
- pub entry_row_height: f64,
- pub padding_x: f64,
- pub label_x: f64,
- pub value_x: f64,
+ pub rows_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.
+ pub stepper_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.
+ pub column_width: f64,
+ /// How tall a column may grow before the next row starts another one.
+ /// Unbounded unless one column would not fit the screen.
+ pub column_budget: f64,
+ /// The hovered part's tooltip box, which may reach past the panel.
+ pub tooltip: Option,
}
#[derive(Debug, Clone)]
pub struct ShapePropertiesPanel {
+ /// The shape type, or how many shapes are selected.
pub title: String,
+ /// Layer position and size, under the title.
+ pub subtitle: Option,
+ /// Identity and creation time, shown as the title's tooltip.
+ pub details: Option,
+ pub lock: PropertiesPanelLock,
pub anchor: (f64, f64),
pub anchor_rect: Option,
- pub lines: Vec,
pub entries: Vec,
- pub hover_index: Option,
+ pub swatches: Vec,
+ /// The selection's shared color, for the thickness preview stroke.
+ pub preview_color: Option,
+ pub hover: Option,
+ /// The part a pointer press landed on; a release activates it only there.
+ pub pressed: Option,
pub keyboard_focus: Option,
+ /// Whether the focus ring shows: keyboard navigation moves focus visibly,
+ /// while a click only remembers the row so arrow keys continue there.
+ pub focus_visible: bool,
pub multiple_selection: bool,
}
+
+impl ShapePropertiesPanel {
+ /// 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/properties/utils.rs b/src/input/state/core/properties/utils.rs
index b4be2f2e4..1310fe468 100644
--- a/src/input/state/core/properties/utils.rs
+++ b/src/input/state/core/properties/utils.rs
@@ -1,16 +1,21 @@
-use crate::draw::{BLACK, BLUE, Color, GREEN, ORANGE, PINK, RED, WHITE, YELLOW};
+use crate::config::QuickColorPaletteEntry;
+use crate::draw::Color;
use crate::time_utils::format_unix_millis;
-pub(super) const SELECTION_COLORS: [(&str, Color); 8] = [
- ("Red", RED),
- ("Green", GREEN),
- ("Blue", BLUE),
- ("Yellow", YELLOW),
- ("Orange", ORANGE),
- ("Pink", PINK),
- ("White", WHITE),
- ("Black", BLACK),
-];
+/// The palette slot `offset` steps from `current`, wrapping at both ends.
+///
+/// A color the palette does not hold starts from outside it: a forward step
+/// lands on the first slot and a backward step on the last, rather than
+/// skipping whichever slot sits next to an arbitrary starting guess.
+pub(super) fn palette_step(len: usize, current: Option, offset: i32) -> Option {
+ if len == 0 {
+ return None;
+ }
+ let Some(index) = current else {
+ return Some(if offset < 0 { len - 1 } else { 0 });
+ };
+ Some(cycle_index(index, len, offset))
+}
pub(super) fn cycle_index(index: usize, len: usize, offset: i32) -> usize {
if len == 0 {
@@ -26,19 +31,20 @@ pub(super) fn cycle_index(index: usize, len: usize, offset: i32) -> usize {
next as usize
}
-pub(super) fn color_palette_index(color: Color) -> Option {
- SELECTION_COLORS
+pub(super) fn color_palette_index(
+ palette: &[QuickColorPaletteEntry],
+ color: Color,
+) -> Option {
+ palette
.iter()
- .position(|(_, candidate)| color_eq(candidate, &color))
+ .position(|entry| color_eq(&entry.color, &color))
}
-pub(super) fn color_label(color: Color) -> String {
- for (name, candidate) in SELECTION_COLORS {
- if color_eq(&candidate, &color) {
- return name.to_string();
- }
- }
- "Custom".to_string()
+/// The palette's name for `color`, or "Custom" for a color it does not hold.
+pub(super) fn color_label(palette: &[QuickColorPaletteEntry], color: Color) -> String {
+ color_palette_index(palette, color)
+ .map(|index| palette[index].label.clone())
+ .unwrap_or_else(|| "Custom".to_string())
}
pub(super) fn color_eq(a: &Color, b: &Color) -> bool {
@@ -56,6 +62,21 @@ pub(super) fn format_timestamp(ms: u64) -> Option {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::domain::color::{PALETTE_BLUE, PALETTE_GREEN, PALETTE_RED};
+
+ fn palette() -> Vec {
+ [
+ ("Red", PALETTE_RED),
+ ("Green", PALETTE_GREEN),
+ ("Blue", PALETTE_BLUE),
+ ]
+ .into_iter()
+ .map(|(label, color)| QuickColorPaletteEntry {
+ label: label.to_string(),
+ color,
+ })
+ .collect()
+ }
#[test]
fn cycle_index_wraps_forward_and_backward() {
@@ -69,17 +90,25 @@ mod tests {
assert_eq!(cycle_index(5, 0, 3), 0);
}
+ #[test]
+ fn palette_step_enters_the_palette_at_the_near_end_from_a_custom_color() {
+ assert_eq!(palette_step(3, None, 1), Some(0));
+ assert_eq!(palette_step(3, None, -1), Some(2));
+ assert_eq!(palette_step(3, Some(2), 1), Some(0));
+ assert_eq!(palette_step(0, None, 1), None);
+ }
+
#[test]
fn color_palette_index_and_label_use_approximate_rgb_matching() {
- let near_red = Color {
- r: RED.r - 0.009,
- g: RED.g,
- b: RED.b + 0.009,
+ let near_green = Color {
+ r: PALETTE_GREEN.r - 0.009,
+ g: PALETTE_GREEN.g,
+ b: PALETTE_GREEN.b + 0.009,
a: 0.25,
};
- assert_eq!(color_palette_index(near_red), Some(0));
- assert_eq!(color_label(near_red), "Red");
+ assert_eq!(color_palette_index(&palette(), near_green), Some(1));
+ assert_eq!(color_label(&palette(), near_green), "Green");
}
#[test]
@@ -91,8 +120,8 @@ mod tests {
a: 1.0,
};
- assert_eq!(color_palette_index(custom), None);
- assert_eq!(color_label(custom), "Custom");
+ assert_eq!(color_palette_index(&palette(), custom), None);
+ assert_eq!(color_label(&palette(), custom), "Custom");
}
#[test]
diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs
index 155089475..f60aaf3c8 100644
--- a/src/input/state/mod.rs
+++ b/src/input/state/mod.rs
@@ -13,6 +13,11 @@ pub(crate) use core::{
DrawingStyle, HistoryLimits, IdleHandle, SpotlightMagnificationTrack, TopMenuState,
};
pub(crate) use core::{InputEffect, InputEffectDrain};
+pub(crate) use core::{
+ PanelRect, 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};
pub(crate) use text_resources::InputTextResources;
@@ -50,11 +55,11 @@ pub use core::{
RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, RegionPurposeTag,
RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START,
SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, SelectionAxis, SelectionHandle,
- SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, ShellMode, SubmenuSide,
- TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep, UI_TOAST_DURATION_MS,
- UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, compass_slice,
- font_picker_layout, font_picker_rows, size_ring_angle_for_value, size_ring_value_for_angle,
- slice_parent, sub_ring_child_count, sub_ring_children,
+ SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue,
+ ShellMode, SubmenuSide, TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep,
+ UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv,
+ compass_slice, font_picker_layout, font_picker_rows, size_ring_angle_for_value,
+ size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children,
};
#[allow(unused_imports)]
pub(crate) use core::{
diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs
index cad9e1c6b..7dac8754f 100644
--- a/src/input/state/mouse/press/panels.rs
+++ b/src/input/state/mouse/press/panels.rs
@@ -193,9 +193,7 @@ impl InputState {
}
match button {
MouseButton::Left => {
- if let Some(index) = self.properties_panel_index_at(x, y) {
- self.set_properties_panel_focus(Some(index));
- } else {
+ if !self.press_properties_panel_at(x, y) {
self.close_properties_panel();
}
}
diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs
index 9a49b7ea3..7b2261086 100644
--- a/src/input/state/mouse/release/panels.rs
+++ b/src/input/state/mouse/release/panels.rs
@@ -235,12 +235,7 @@ pub(super) fn handle_properties_panel_release(
if state.properties_panel_layout().is_none() {
return true;
}
- if let Some(index) = state.properties_panel_index_at(x, y) {
- state.set_properties_panel_focus(Some(index));
- state.activate_properties_panel_entry_with(measurer);
- } else {
- state.close_properties_panel();
- }
+ state.release_properties_panel_at_with(measurer, x, y);
state.needs_redraw = true;
true
}
diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs
index c2591c3f6..a22a44efb 100644
--- a/src/input/state/tests/mod.rs
+++ b/src/input/state/tests/mod.rs
@@ -29,6 +29,7 @@ mod pages;
mod presenter_mode;
mod pressure_modes;
mod properties_panel;
+mod properties_panel_controls;
mod radial_menu;
mod selection;
mod session_preflight;
diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs
index 8e85f60ec..ee66efd85 100644
--- a/src/input/state/tests/properties_panel.rs
+++ b/src/input/state/tests/properties_panel.rs
@@ -35,18 +35,20 @@ fn show_properties_panel_for_single_shape_reports_type_layer_and_lock_state() {
assert!(state.show_properties_panel_with(&route_measurer));
let panel = state.properties_panel().expect("properties panel");
- assert_eq!(panel.title, "Shape Properties");
+ assert_eq!(panel.title, "Rectangle");
assert!(!panel.multiple_selection);
+ let subtitle = panel.subtitle.as_deref().expect("subtitle");
+ assert!(subtitle.starts_with("Layer 1 of 1 · "), "{subtitle}");
assert!(
panel
- .lines
- .iter()
- .any(|line| line == &format!("Shape ID: {shape_id}"))
+ .details
+ .as_deref()
+ .is_some_and(|details| details.starts_with(&format!("Shape ID {shape_id}")))
+ );
+ assert_eq!(
+ panel.lock,
+ crate::input::state::PropertiesPanelLock::Unlocked
);
- assert!(panel.lines.iter().any(|line| line == "Type: Rectangle"));
- assert!(panel.lines.iter().any(|line| line == "Layer: 1 of 1"));
- assert!(panel.lines.iter().any(|line| line == "Locked: No"));
- assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: ")));
}
#[test]
@@ -66,11 +68,14 @@ fn show_properties_panel_for_multi_selection_includes_locked_count_and_summary()
assert!(state.show_properties_panel_with(&route_measurer));
let panel = state.properties_panel().expect("properties panel");
- assert_eq!(panel.title, "Selection Properties");
+ assert_eq!(panel.title, "2 shapes");
assert!(panel.multiple_selection);
- assert!(panel.lines.iter().any(|line| line == "Shapes selected: 2"));
- assert!(panel.lines.iter().any(|line| line == "Locked: 1/2"));
- assert!(panel.lines.iter().any(|line| line.starts_with("Bounds: ")));
+ let subtitle = panel.subtitle.as_deref().expect("subtitle");
+ assert!(subtitle.ends_with(" px · 1 of 2 locked"), "{subtitle}");
+ assert_eq!(
+ panel.lock,
+ crate::input::state::PropertiesPanelLock::Partial
+ );
}
#[test]
diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs
new file mode 100644
index 000000000..d23e3825b
--- /dev/null
+++ b/src/input/state/tests/properties_panel_controls.rs
@@ -0,0 +1,546 @@
+//! Pointer, wheel, and keyboard use of the properties panel's controls.
+
+use super::*;
+use crate::domain::color::{PALETTE_GREEN, PALETTE_RED};
+use crate::draw::{ArrowStyle, ShapeId, TextMeasurer};
+use crate::input::state::{PropertiesPanelHit, PropertiesPanelLock};
+
+const SCREEN: (u32, u32) = (1280, 800);
+
+fn lay_out(state: &mut InputState) {
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ state.update_properties_panel_layout(&ctx, SCREEN.0, SCREEN.1);
+}
+
+fn open(state: &mut InputState, ids: Vec) {
+ state.set_selection(ids);
+ assert!(state.show_properties_panel_with(&TextMeasurer::default()));
+ lay_out(state);
+}
+
+fn add_rect(state: &mut InputState, color: Color, fill: bool) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x: 100,
+ y: 100,
+ w: 60,
+ h: 40,
+ fill,
+ color,
+ thick: 3.0,
+ })
+}
+
+fn add_arrow(state: &mut InputState, style: ArrowStyle, head_at_end: bool) -> ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Arrow {
+ x1: 100,
+ y1: 200,
+ x2: 300,
+ y2: 240,
+ color: PALETTE_RED,
+ thick: 3.0,
+ arrow_length: 24.0,
+ arrow_angle: 35.0,
+ head_at_end,
+ style,
+ bend: 0.0,
+ label: None,
+ })
+}
+
+fn row(state: &InputState, label: &str) -> usize {
+ state
+ .properties_panel()
+ .expect("panel")
+ .entries
+ .iter()
+ .position(|entry| entry.label == label)
+ .expect(label)
+}
+
+/// The center of `hit` as drawn this frame.
+fn point(state: &InputState, hit: PropertiesPanelHit) -> (i32, i32) {
+ let layout = state.properties_panel_layout().expect("layout");
+ let rect = layout
+ .hit_rect(state.properties_panel().expect("panel"), hit)
+ .unwrap_or_else(|| panic!("{hit:?} is drawn"));
+ let (x, y) = rect.center();
+ (x.round() as i32, y.round() as i32)
+}
+
+/// A full left click on `hit`: press and release on the same spot.
+fn click(state: &mut InputState, hit: PropertiesPanelHit) {
+ let (x, y) = point(state, hit);
+ click_at(state, (x, y), (x, y));
+}
+
+fn click_at(state: &mut InputState, press: (i32, i32), release: (i32, i32)) {
+ assert!(state.handle_properties_panel_press(MouseButton::Left, press.0, press.1));
+ assert!(state.handle_properties_panel_release_at_with_measurer(
+ &TextMeasurer::default(),
+ release.0,
+ release.1
+ ));
+ lay_out(state);
+}
+
+fn rect_of(state: &InputState, id: ShapeId) -> (Color, bool, f64) {
+ match &state.boards.active_frame().shape(id).expect("rect").shape {
+ Shape::Rect {
+ color, fill, thick, ..
+ } => (*color, *fill, *thick),
+ other => panic!("expected rect, got {other:?}"),
+ }
+}
+
+fn arrow_of(state: &InputState, id: ShapeId) -> (ArrowStyle, bool, f64) {
+ match &state.boards.active_frame().shape(id).expect("arrow").shape {
+ Shape::Arrow {
+ style,
+ head_at_end,
+ bend,
+ ..
+ } => (*style, *head_at_end, *bend),
+ other => panic!("expected arrow, got {other:?}"),
+ }
+}
+
+fn undo_depth(state: &InputState) -> usize {
+ state.boards.active_frame().undo_stack_len()
+}
+
+#[test]
+fn clicking_a_swatch_recolors_the_selection_and_rings_that_swatch() {
+ 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");
+ let green = state
+ .properties_panel()
+ .unwrap()
+ .swatches
+ .iter()
+ .position(|swatch| swatch.color == PALETTE_GREEN)
+ .expect("green swatch");
+
+ click(
+ &mut state,
+ PropertiesPanelHit::Swatch {
+ row: color_row,
+ index: green,
+ },
+ );
+
+ assert_eq!(rect_of(&state, id).0, PALETTE_GREEN);
+ let panel = state.properties_panel().expect("panel");
+ assert_eq!(panel.entries[color_row].value, "Green");
+ assert_eq!(panel.current_swatch(&panel.entries[color_row]), Some(green));
+}
+
+#[test]
+fn clicking_the_swatch_the_selection_already_has_is_a_quiet_no_op() {
+ 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");
+ let depth = undo_depth(&state);
+
+ click(
+ &mut state,
+ PropertiesPanelHit::Swatch {
+ row: color_row,
+ index: 0,
+ },
+ );
+
+ assert_eq!(rect_of(&state, id).0, PALETTE_RED);
+ assert_eq!(undo_depth(&state), depth, "no edit, so no undo entry");
+ assert!(!state.has_active_toast(), "and no \"No changes\" toast");
+}
+
+#[test]
+fn stepper_buttons_step_thickness_down_and_up() {
+ 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");
+
+ 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);
+ assert_eq!(
+ state.properties_panel().unwrap().entries[thickness].value,
+ "2.0px"
+ );
+}
+
+#[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);
+ open(&mut state, vec![id]);
+ let thickness = row(&state, "Thickness");
+ let up = point(&state, PropertiesPanelHit::StepUp(thickness));
+ let down = point(&state, PropertiesPanelHit::StepDown(thickness));
+
+ click_at(&mut state, up, down);
+
+ assert_eq!(rect_of(&state, id).2, 3.0);
+ assert!(state.is_properties_panel_open());
+}
+
+#[test]
+fn the_switch_and_the_rest_of_its_row_both_toggle_fill() {
+ 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::Toggle(fill));
+ assert!(rect_of(&state, id).1);
+ assert_eq!(state.properties_panel().unwrap().entries[fill].value, "On");
+
+ click(&mut state, PropertiesPanelHit::Row(fill));
+ assert!(!rect_of(&state, id).1);
+}
+
+#[test]
+fn a_mixed_fill_selection_turns_on_from_the_switch() {
+ 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);
+ open(&mut state, vec![filled, outlined]);
+ let fill = row(&state, "Fill");
+ assert_eq!(
+ state.properties_panel().unwrap().entries[fill].state,
+ SelectionPropertyValue::Toggle(None)
+ );
+
+ click(&mut state, PropertiesPanelHit::Toggle(fill));
+
+ assert!(rect_of(&state, filled).1 && rect_of(&state, outlined).1);
+}
+
+#[test]
+fn head_segments_move_the_arrow_head_and_ignore_the_active_one() {
+ let mut state = create_test_input_state();
+ let id = add_arrow(&mut state, ArrowStyle::Standard, true);
+ open(&mut state, vec![id]);
+ let head = row(&state, "Arrow head");
+ let depth = undo_depth(&state);
+
+ click(
+ &mut state,
+ PropertiesPanelHit::ArrowHead {
+ row: head,
+ at_end: true,
+ },
+ );
+ assert_eq!(undo_depth(&state), depth, "End is already set");
+
+ click(
+ &mut state,
+ PropertiesPanelHit::ArrowHead {
+ row: head,
+ at_end: false,
+ },
+ );
+ assert!(!arrow_of(&state, id).1);
+ assert_eq!(
+ state.properties_panel().unwrap().entries[head].value,
+ "Start"
+ );
+}
+
+#[test]
+fn style_buttons_pick_that_style_and_curved_gets_an_arc() {
+ let mut state = create_test_input_state();
+ let id = add_arrow(&mut state, ArrowStyle::Standard, true);
+ open(&mut state, vec![id]);
+ let style = row(&state, "Arrow style");
+
+ click(
+ &mut state,
+ PropertiesPanelHit::ArrowStyle {
+ row: style,
+ style: ArrowStyle::Double,
+ },
+ );
+ assert_eq!(arrow_of(&state, id).0, ArrowStyle::Double);
+
+ click(
+ &mut state,
+ PropertiesPanelHit::ArrowStyle {
+ row: style,
+ style: ArrowStyle::Curved,
+ },
+ );
+ let (current, _, bend) = arrow_of(&state, id);
+ assert_eq!(current, ArrowStyle::Curved);
+ assert_ne!(bend, 0.0, "a curved arrow needs an arc to show");
+ assert_eq!(
+ state.properties_panel().unwrap().entries[style].state,
+ SelectionPropertyValue::ArrowStyle(Some(ArrowStyle::Curved))
+ );
+}
+
+#[test]
+fn the_lock_button_locks_the_selection_and_its_rows_go_inert() {
+ 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 up = point(&state, PropertiesPanelHit::StepUp(thickness));
+
+ click(&mut state, PropertiesPanelHit::Lock);
+
+ assert!(state.boards.active_frame().shape(id).expect("rect").locked);
+ let panel = state.properties_panel().expect("panel");
+ assert_eq!(panel.lock, PropertiesPanelLock::Locked);
+ assert!(panel.entries.iter().all(|entry| entry.disabled));
+ assert_eq!(state.properties_panel_active_hit_at(up.0, up.1), None);
+ click_at(&mut state, up, up);
+ assert_eq!(rect_of(&state, id).2, 3.0, "a locked row ignores clicks");
+
+ click(&mut state, PropertiesPanelHit::Lock);
+ assert!(!state.boards.active_frame().shape(id).expect("rect").locked);
+ assert!(
+ !state.properties_panel().unwrap().entries[thickness].disabled,
+ "unlocking brings the rows back"
+ );
+}
+
+#[test]
+fn a_press_on_the_header_keeps_the_panel_open() {
+ 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::Title);
+
+ assert!(state.is_properties_panel_open());
+}
+
+#[test]
+fn wheel_over_a_row_steps_it_and_the_rest_of_the_panel_swallows_it() {
+ 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 thickness = row(&state, "Thickness");
+ let (x, y) = point(&state, PropertiesPanelHit::Row(thickness));
+
+ // Scrolling up (a negative axis direction) raises the value.
+ assert!(state.properties_panel_wheel_with(&measurer, x, y, -1));
+ assert_eq!(rect_of(&state, id).2, 4.0);
+ assert!(state.properties_panel_wheel_with(&measurer, x, y, 1));
+ assert_eq!(rect_of(&state, id).2, 3.0);
+
+ let (title_x, title_y) = point(&state, PropertiesPanelHit::Title);
+ assert!(
+ state.properties_panel_wheel_with(&measurer, title_x, title_y, -1),
+ "the header takes the tick so the tool behind does not"
+ );
+ assert_eq!(rect_of(&state, id).2, 3.0);
+
+ let layout = *state.properties_panel_layout().unwrap();
+ let off_panel = (layout.origin_x - 20.0) as i32;
+ assert!(!state.properties_panel_wheel_with(&measurer, off_panel, y, -1));
+}
+
+#[test]
+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);
+ open(&mut state, vec![id]);
+ let thickness = row(&state, "Thickness");
+
+ click(&mut state, PropertiesPanelHit::StepUp(thickness));
+ let panel = state.properties_panel().unwrap();
+ assert_eq!(panel.keyboard_focus, Some(thickness));
+ 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!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Down));
+ assert!(state.properties_panel().unwrap().focus_visible);
+}
+
+#[test]
+fn shift_arrow_keys_step_numbers_five_at_a_time() {
+ 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 thickness = row(&state, "Thickness");
+ state.set_properties_panel_focus(Some(thickness));
+
+ state.modifiers.shift = true;
+ assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right));
+
+ assert_eq!(rect_of(&state, id).2, 8.0);
+}
+
+#[test]
+fn more_colors_opens_the_picker_over_the_panel_and_ok_recolors_the_selection() {
+ 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");
+ let custom = Color {
+ r: 0.2,
+ g: 0.4,
+ b: 0.6,
+ a: 1.0,
+ };
+
+ click(&mut state, PropertiesPanelHit::MoreColors(color_row));
+
+ 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(), "Selection Color");
+ assert_eq!(state.color_picker_popup_current_color(), Some(PALETTE_RED));
+ assert!(state.modal_owns_wheel(), "the picker covers the panel");
+
+ state.color_picker_popup_set_color(custom);
+ assert_eq!(
+ rect_of(&state, id).0,
+ PALETTE_RED,
+ "nothing changes before OK"
+ );
+ state.apply_color_picker_popup();
+
+ assert!(!state.is_color_picker_popup_open());
+ assert_eq!(rect_of(&state, id).0, custom);
+ assert_eq!(state.recent_colors().first(), Some(&custom));
+ lay_out(&mut state);
+ assert_eq!(
+ state.properties_panel().unwrap().entries[color_row].value,
+ "Custom"
+ );
+}
+
+#[test]
+fn cancelling_the_selection_picker_leaves_the_shapes_alone() {
+ 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));
+
+ state.color_picker_popup_set_color(PALETTE_GREEN);
+ state.close_color_picker_popup(true);
+
+ assert_eq!(rect_of(&state, id).0, PALETTE_RED);
+ 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();
+ let red = add_rect(&mut state, PALETTE_RED, false);
+ let green = add_rect(&mut state, PALETTE_GREEN, false);
+ open(&mut state, vec![red, green]);
+ let color_row = row(&state, "Color");
+ click(&mut state, PropertiesPanelHit::MoreColors(color_row));
+ let opened_on = state.color_picker_popup_current_color().expect("picker");
+
+ state.apply_color_picker_popup();
+
+ assert_eq!(rect_of(&state, red).0, opened_on);
+ assert_eq!(rect_of(&state, green).0, opened_on);
+}
+
+#[test]
+fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() {
+ let mut state = create_test_input_state();
+ let faint_red = Color {
+ a: 0.25,
+ ..PALETTE_RED
+ };
+ 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");
+
+ click(
+ &mut state,
+ PropertiesPanelHit::Swatch {
+ row: color_row,
+ index: red,
+ },
+ );
+
+ assert_eq!(rect_of(&state, id).0, PALETTE_RED, "now opaque");
+}
+
+#[test]
+fn a_marker_keeps_its_opacity_so_its_own_hue_is_a_quiet_no_op() {
+ let mut state = create_test_input_state();
+ let marker = state
+ .boards
+ .active_frame_mut()
+ .add_shape(Shape::MarkerStroke {
+ points: vec![(100, 100), (200, 120)],
+ color: Color {
+ a: 0.3,
+ ..PALETTE_RED
+ },
+ thick: 12.0,
+ });
+ open(&mut state, vec![marker]);
+ let color_row = row(&state, "Color");
+ let depth = undo_depth(&state);
+
+ click(
+ &mut state,
+ PropertiesPanelHit::Swatch {
+ row: color_row,
+ index: 0,
+ },
+ );
+
+ assert_eq!(undo_depth(&state), depth);
+ assert!(!state.has_active_toast());
+ match &state.boards.active_frame().shape(marker).unwrap().shape {
+ Shape::MarkerStroke { color, .. } => assert_eq!(color.a, 0.3),
+ other => panic!("expected marker, got {other:?}"),
+ }
+}
+
+#[test]
+fn ok_evens_out_shapes_that_differ_only_in_opacity() {
+ let mut state = create_test_input_state();
+ let solid = add_rect(&mut state, PALETTE_RED, false);
+ let faint = add_rect(
+ &mut state,
+ Color {
+ a: 0.25,
+ ..PALETTE_RED
+ },
+ false,
+ );
+ open(&mut state, vec![solid, faint]);
+ let color_row = row(&state, "Color");
+ click(&mut state, PropertiesPanelHit::MoreColors(color_row));
+ assert_eq!(state.color_picker_popup_current_color(), Some(PALETTE_RED));
+
+ state.apply_color_picker_popup();
+
+ assert_eq!(rect_of(&state, solid).0, PALETTE_RED);
+ assert_eq!(rect_of(&state, faint).0, PALETTE_RED);
+}
diff --git a/src/toolbar_gtk/view/top_bar/tests/expectations.rs b/src/toolbar_gtk/view/top_bar/tests/expectations.rs
index e96e3c5cb..3e1100a6e 100644
--- a/src/toolbar_gtk/view/top_bar/tests/expectations.rs
+++ b/src/toolbar_gtk/view/top_bar/tests/expectations.rs
@@ -139,6 +139,7 @@ fn selection_property_entry(
label: label.to_string(),
value: value.to_string(),
kind,
+ state: kind.unknown_value(),
disabled,
}
}
diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs
index 657d9739e..c8e200c09 100644
--- a/src/ui/properties_panel.rs
+++ b/src/ui/properties_panel.rs
@@ -1,24 +1,54 @@
+//! The shape properties panel: a header naming the selection, one control per
+//! property (swatches, steppers, switches, the arrow-head segments, drawn
+//! arrow styles), and a keyboard hint strip.
+//!
+//! Every rectangle comes from the panel layout's geometry, the same one the
+//! input side hit-tests against.
+
+use cairo::FontWeight;
+
use crate::input::InputState;
-use crate::ui::primitives::draw_rounded_rect;
-use crate::ui_text::{UiTextEngine, UiTextStyle};
+use crate::input::state::properties_panel_metrics::{
+ BODY_FONT, FOOTER_FONT, FOOTER_HEIGHT, SUBTITLE_FONT, SWITCH_VALUE_GAP, TITLE_FONT,
+ TOOLTIP_FONT, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y, VALUE_FONT, text_style,
+};
+use crate::input::state::{
+ PropertiesPanelHit, PropertiesPanelLayout, PropertiesRowControl, PropertiesRowGeometry,
+ ShapePropertiesPanel,
+};
+use crate::input::{SelectionPropertyEntry, SelectionPropertyValue};
+use crate::ui::primitives::{draw_keycap_with_engine, draw_rounded_rect, keycap_size_with_engine};
+use crate::ui::theme::overlay::{
+ BG_HOVER_WASH, BORDER_FOCUS, BORDER_PROPERTIES, DIVIDER, DIVIDER_LIGHT, EMPTY_PROPERTIES,
+ FOCUS_RING_WIDTH, PANEL_BG_CONTEXT_SUBMENU, RADIUS_PANEL, RADIUS_STD, TEXT_DISABLED, TEXT_HINT,
+ TEXT_PRIMARY, TEXT_SECONDARY, TEXT_TERTIARY,
+};
+use crate::ui::theme::{Rgba, set_color, with_alpha};
+use crate::ui_text::UiTextEngine;
+
+mod controls;
-use super::constants::{
- self, BG_HOVER, BORDER_FOCUS, DIVIDER, EMPTY_PROPERTIES, FOCUS_RING_WIDTH, RADIUS_PANEL,
- RADIUS_SM, TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_TERTIARY,
+use controls::{
+ ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_stepper,
+ draw_swatches, draw_switch,
};
+/// Wash behind the hovered row: a quieter `BG_HOVER`, so the control under
+/// the pointer still stands out inside it.
+const ROW_HOVER: Rgba = (0.25, 0.32, 0.45, 0.45);
+
pub fn render_properties_panel(
ctx: &cairo::Context,
input_state: &InputState,
- _screen_width: u32,
- _screen_height: u32,
+ screen_width: u32,
+ screen_height: u32,
) {
render_properties_panel_with_engine(
&UiTextEngine::default(),
ctx,
input_state,
- _screen_width,
- _screen_height,
+ screen_width,
+ screen_height,
);
}
@@ -29,34 +59,16 @@ pub(crate) fn render_properties_panel_with_engine(
_screen_width: u32,
_screen_height: u32,
) {
- let panel = match input_state.properties_panel() {
- Some(panel) => panel,
- None => return,
- };
- let layout = match input_state.properties_panel_layout() {
- Some(layout) => layout,
- None => return,
- };
-
- let title_font_size = 15.0;
- let body_font_size = 13.0;
- let line_height = 18.0;
- let title_style = UiTextStyle {
- family: "Sans",
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Bold,
- size: title_font_size,
- };
- let body_style = UiTextStyle {
- family: "Sans",
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Normal,
- size: body_font_size,
+ let (Some(panel), Some(layout)) = (
+ input_state.properties_panel(),
+ input_state.properties_panel_layout(),
+ ) else {
+ return;
};
let _ = ctx.save();
// Background and hairline border (popover radius, matching the other
- // overlay popups)
+ // overlay popups).
draw_rounded_rect(
ctx,
layout.origin_x,
@@ -65,128 +77,274 @@ pub(crate) fn render_properties_panel_with_engine(
layout.height,
RADIUS_PANEL,
);
- constants::set_color(ctx, crate::ui::theme::popup::bg_properties());
+ set_color(ctx, crate::ui::theme::popup::bg_properties());
let _ = ctx.fill_preserve();
- constants::set_color(ctx, crate::ui::theme::popup::border_properties());
+ set_color(ctx, crate::ui::theme::popup::border_properties());
ctx.set_line_width(1.0);
let _ = ctx.stroke();
- if panel.multiple_selection {
- constants::set_color(ctx, TEXT_SECONDARY);
- } else {
- constants::set_color(ctx, TEXT_PRIMARY);
- }
+ // Header: the shape type (or count), its layer and size, and the lock.
+ set_color(
+ ctx,
+ if panel.multiple_selection {
+ TEXT_SECONDARY
+ } else {
+ TEXT_PRIMARY
+ },
+ );
engine.draw_baseline(
ctx,
- title_style,
+ text_style(TITLE_FONT, FontWeight::Bold),
&panel.title,
- layout.label_x,
+ layout.content_x(),
layout.title_baseline_y,
None,
);
-
- constants::set_color(ctx, DIVIDER);
- ctx.move_to(layout.label_x, layout.divider_y);
- ctx.line_to(
- layout.origin_x + layout.width - layout.padding_x,
- layout.divider_y,
+ if let (Some(subtitle), Some(baseline)) = (&panel.subtitle, layout.subtitle_baseline_y) {
+ set_color(ctx, TEXT_TERTIARY);
+ engine.draw_baseline(
+ ctx,
+ text_style(SUBTITLE_FONT, FontWeight::Normal),
+ subtitle,
+ layout.content_x(),
+ baseline,
+ None,
+ );
+ }
+ draw_lock(
+ ctx,
+ layout.lock,
+ panel.lock,
+ panel.hover == Some(PropertiesPanelHit::Lock),
);
+
+ set_color(ctx, DIVIDER);
+ ctx.set_line_width(1.0);
+ ctx.move_to(layout.content_x(), layout.divider_y + 0.5);
+ ctx.line_to(layout.content_right(), layout.divider_y + 0.5);
let _ = ctx.stroke();
- // Show empty state if no content
- if panel.lines.is_empty() && panel.entries.is_empty() {
- let empty_style = UiTextStyle {
- family: "Sans",
+ if panel.entries.is_empty() {
+ let empty_style = crate::ui_text::UiTextStyle {
slant: cairo::FontSlant::Italic,
- weight: cairo::FontWeight::Normal,
- size: body_font_size,
+ ..text_style(BODY_FONT, FontWeight::Normal)
};
- constants::set_color(ctx, TEXT_TERTIARY);
- let empty_y = layout.info_start_y + line_height;
+ set_color(ctx, TEXT_TERTIARY);
engine.draw_baseline(
ctx,
empty_style,
EMPTY_PROPERTIES,
- layout.label_x,
- empty_y,
+ layout.content_x(),
+ layout.rows_top + BODY_FONT + 4.0,
None,
);
- let _ = ctx.restore();
- return;
+ } else {
+ for row in layout.rows(panel) {
+ let entry = &panel.entries[row.index];
+ draw_row(engine, ctx, panel, &row, entry);
+ }
+ }
+
+ if let Some(footer_top) = layout.footer_top {
+ draw_footer(engine, ctx, layout, footer_top);
}
- constants::set_color(ctx, TEXT_SECONDARY);
- let mut text_y = layout.info_start_y;
- for line in &panel.lines {
- engine.draw_baseline(ctx, body_style, line, layout.label_x, text_y, None);
- text_y += line_height;
+ if let (Some(hit), Some(rect)) = (panel.hover, layout.tooltip)
+ && let Some(text) = panel.tooltip(hit)
+ {
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD);
+ set_color(ctx, PANEL_BG_CONTEXT_SUBMENU);
+ let _ = ctx.fill_preserve();
+ set_color(ctx, BORDER_PROPERTIES);
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+ set_color(ctx, TEXT_PRIMARY);
+ engine.draw_baseline(
+ ctx,
+ text_style(TOOLTIP_FONT, FontWeight::Normal),
+ &text,
+ rect.x + TOOLTIP_PADDING_X,
+ rect.y + TOOLTIP_PADDING_Y + TOOLTIP_FONT,
+ None,
+ );
+ }
+
+ let _ = ctx.restore();
+}
+
+fn draw_row(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ panel: &ShapePropertiesPanel,
+ row: &PropertiesRowGeometry,
+ entry: &SelectionPropertyEntry,
+) {
+ let index = row.index;
+ let enabled = !entry.disabled;
+ let hover = panel.hover.filter(|hit| hit.row() == Some(index));
+ let rect = row.rect;
+
+ if enabled && hover.is_some() {
+ set_color(ctx, ROW_HOVER);
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD);
+ let _ = ctx.fill();
+ }
+ if enabled && panel.focus_visible && panel.keyboard_focus == Some(index) {
+ set_color(ctx, BORDER_FOCUS);
+ ctx.set_line_width(FOCUS_RING_WIDTH);
+ draw_rounded_rect(
+ ctx,
+ rect.x + 1.0,
+ rect.y + 1.0,
+ rect.width - 2.0,
+ rect.height - 2.0,
+ RADIUS_STD,
+ );
+ let _ = ctx.stroke();
}
- if !panel.entries.is_empty() {
- for (index, entry) in panel.entries.iter().enumerate() {
- let row_top = layout.entry_start_y + layout.entry_row_height * index as f64;
- let row_center = row_top + layout.entry_row_height * 0.5;
-
- // Distinguish hover from keyboard focus
- let is_hovered = panel.hover_index == Some(index) && !entry.disabled;
- let is_focused = panel.keyboard_focus == Some(index) && !entry.disabled;
-
- if is_hovered {
- constants::set_color(ctx, BG_HOVER);
- draw_rounded_rect(
- ctx,
- layout.origin_x + 4.0,
- row_top,
- layout.width - 8.0,
- layout.entry_row_height,
- RADIUS_SM,
- );
- let _ = ctx.fill();
- }
-
- if is_focused && !is_hovered {
- // Draw focus ring for keyboard navigation
- constants::set_color(ctx, BORDER_FOCUS);
- ctx.set_line_width(FOCUS_RING_WIDTH);
- draw_rounded_rect(
- ctx,
- layout.origin_x + 2.0,
- row_top + 1.0,
- layout.width - 4.0,
- layout.entry_row_height - 2.0,
- RADIUS_SM,
- );
- let _ = ctx.stroke();
- }
-
- let text_color = if entry.disabled {
- TEXT_DISABLED
- } else {
- TEXT_PRIMARY
+ set_color(ctx, if enabled { TEXT_PRIMARY } else { TEXT_DISABLED });
+ engine.draw_baseline(
+ ctx,
+ text_style(BODY_FONT, FontWeight::Normal),
+ &entry.label,
+ row.content_x,
+ row.label_baseline_y,
+ None,
+ );
+
+ let state = ControlState { enabled, hover };
+ match &row.control {
+ PropertiesRowControl::Swatches { swatches, more } => {
+ draw_value_right(engine, ctx, row, entry, enabled);
+ let current = panel.current_swatch(entry);
+ draw_swatches(ctx, &panel.swatches, swatches, *more, current, state);
+ }
+ PropertiesRowControl::ArrowStyles { buttons } => {
+ draw_value_right(engine, ctx, row, entry, enabled);
+ let current = match entry.state {
+ SelectionPropertyValue::ArrowStyle(style) => style,
+ _ => None,
};
- let text_a = text_color.3;
- constants::set_color(ctx, text_color);
- engine.draw_baseline(
+ draw_arrow_styles(engine, ctx, buttons, current, state);
+ }
+ PropertiesRowControl::Stepper {
+ down,
+ value,
+ up,
+ preview,
+ } => {
+ let thickness = match entry.state {
+ SelectionPropertyValue::Number(value) => value,
+ _ => None,
+ };
+ draw_stepper(
+ engine,
ctx,
- body_style,
- &entry.label,
- layout.label_x,
- row_center + body_font_size * 0.35,
- None,
+ (*down, *value, *up),
+ entry.stepper_text(),
+ preview.zip(thickness),
+ panel.preview_color,
+ state,
);
-
- let value_color = constants::with_alpha(TEXT_HINT, text_a);
- constants::set_color(ctx, value_color);
+ }
+ PropertiesRowControl::Toggle { switch } => {
+ let on = match entry.state {
+ SelectionPropertyValue::Toggle(on) => on,
+ _ => None,
+ };
+ set_color(ctx, if enabled { TEXT_HINT } else { TEXT_DISABLED });
+ let style = text_style(VALUE_FONT, FontWeight::Normal);
+ let width = engine
+ .layout(ctx, style, &entry.value, None)
+ .ink_extents()
+ .x_advance();
engine.draw_baseline(
ctx,
- body_style,
+ style,
&entry.value,
- layout.value_x,
- row_center + body_font_size * 0.35,
+ switch.x - SWITCH_VALUE_GAP - width,
+ row.label_baseline_y,
None,
);
+ draw_switch(ctx, *switch, on, state);
+ }
+ PropertiesRowControl::ArrowHead { well, start, end } => {
+ let at_end = match entry.state {
+ SelectionPropertyValue::ArrowHead(at_end) => at_end,
+ _ => None,
+ };
+ draw_arrow_head_segments(engine, ctx, *well, *start, *end, at_end, state);
}
}
+}
- let _ = ctx.restore();
+/// A block row's value, right-aligned on its label line: which color or
+/// style the selection has, or "Mixed" / "Locked".
+fn draw_value_right(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ row: &PropertiesRowGeometry,
+ entry: &SelectionPropertyEntry,
+ enabled: bool,
+) {
+ let style = text_style(VALUE_FONT, FontWeight::Normal);
+ let width = engine
+ .layout(ctx, style, &entry.value, None)
+ .ink_extents()
+ .x_advance();
+ set_color(ctx, if enabled { TEXT_HINT } else { TEXT_DISABLED });
+ engine.draw_baseline(
+ ctx,
+ style,
+ &entry.value,
+ row.content_right - width,
+ row.label_baseline_y,
+ None,
+ );
+}
+
+/// The keyboard hint strip: arrow keys pick a row and adjust it, and the
+/// wheel steps whichever row is under the pointer.
+fn draw_footer(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ layout: &PropertiesPanelLayout,
+ footer_top: f64,
+) {
+ set_color(ctx, with_alpha(DIVIDER_LIGHT, 0.4));
+ ctx.set_line_width(1.0);
+ ctx.move_to(layout.content_x(), footer_top + 4.5);
+ ctx.line_to(layout.content_right(), footer_top + 4.5);
+ let _ = ctx.stroke();
+
+ // Keys and words share one center line, whatever each keycap's ink.
+ let style = text_style(FOOTER_FONT, FontWeight::Normal);
+ let center_y = footer_top + 4.0 + (FOOTER_HEIGHT - 4.0) / 2.0;
+ let baseline = center_y + FOOTER_FONT * 0.35;
+ let mut x = layout.content_x();
+ let segments: [(Option<&str>, &str); 3] = [
+ (Some("↑↓"), "row"),
+ (Some("←→"), "adjust"),
+ (None, "scroll to step"),
+ ];
+ for (key, text) in segments {
+ if let Some(key) = key {
+ let (_, height) = keycap_size_with_engine(engine, ctx, key, FOOTER_FONT);
+ let (width, _) = draw_keycap_with_engine(
+ engine,
+ ctx,
+ x,
+ center_y - height / 2.0,
+ key,
+ FOOTER_FONT,
+ BG_HOVER_WASH,
+ TEXT_SECONDARY,
+ );
+ x += width + 4.0;
+ }
+ set_color(ctx, TEXT_TERTIARY);
+ let extents = engine.draw_baseline(ctx, style, text, x, baseline, None);
+ x += extents.x_advance() + 12.0;
+ }
}
diff --git a/src/ui/properties_panel/controls.rs b/src/ui/properties_panel/controls.rs
new file mode 100644
index 000000000..3638da60d
--- /dev/null
+++ b/src/ui/properties_panel/controls.rs
@@ -0,0 +1,442 @@
+//! Painters for the properties panel's controls. Each takes the rectangles
+//! the panel geometry laid out, so drawing never re-derives a position.
+
+use std::f64::consts::{PI, TAU};
+
+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,
+};
+use crate::input::state::{
+ PanelRect, PropertiesPanelHit, PropertiesPanelLock, PropertiesPanelSwatch,
+};
+use crate::toolbar_icons::draw_arrow_style_preview;
+use crate::ui::primitives::{checkerboard_behind, draw_rounded_rect};
+use crate::ui::theme::overlay::{
+ ACCENT_BRIGHT, ACCENT_PRIMARY, BG_HOVER, BG_HOVER_WASH, PANEL_BG_PROPERTIES, RADIUS_MD,
+ RADIUS_SM, RADIUS_STD, TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_TERTIARY,
+ TEXT_WHITE,
+};
+use crate::ui::theme::swatch::{chrome_rgb, swatch_edge_stroke};
+use crate::ui::theme::{Rgba, set_color, with_alpha};
+use crate::ui_text::UiTextEngine;
+
+/// A switch's track while off: a faint well on the panel.
+const SWITCH_TRACK_OFF: Rgba = (1.0, 1.0, 1.0, 0.14);
+/// Hairline around a swatch that already contrasts with the panel.
+const SWATCH_HAIRLINE: Rgba = (1.0, 1.0, 1.0, 0.18);
+/// Outline of the "more colors" button and the style buttons at rest.
+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);
+/// 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;
+
+/// What a control needs to know about its row this frame.
+#[derive(Clone, Copy)]
+pub(super) struct ControlState {
+ pub(super) enabled: bool,
+ /// The row's part under the pointer, if any.
+ pub(super) hover: Option,
+}
+
+impl ControlState {
+ fn hovers(&self, matches: impl Fn(PropertiesPanelHit) -> bool) -> bool {
+ self.enabled && self.hover.is_some_and(matches)
+ }
+
+ fn fade(&self, color: Rgba) -> Rgba {
+ if self.enabled {
+ color
+ } else {
+ with_alpha(color, color.3 * DISABLED_ALPHA)
+ }
+ }
+}
+
+fn rgba(color: Color) -> Rgba {
+ (color.r, color.g, color.b, color.a)
+}
+
+fn round_line(ctx: &cairo::Context, from: (f64, f64), to: (f64, f64)) {
+ ctx.set_line_width(GLYPH_WIDTH);
+ ctx.set_line_cap(cairo::LineCap::Round);
+ ctx.move_to(from.0, from.1);
+ ctx.line_to(to.0, to.1);
+ let _ = ctx.stroke();
+}
+
+fn minus_glyph(ctx: &cairo::Context, (cx, cy): (f64, f64)) {
+ round_line(ctx, (cx - 4.0, cy), (cx + 4.0, cy));
+}
+
+fn plus_glyph(ctx: &cairo::Context, (cx, cy): (f64, f64)) {
+ minus_glyph(ctx, (cx, cy));
+ round_line(ctx, (cx, cy - 4.0), (cx, cy + 4.0));
+}
+
+/// A short arrow for the Start/End segments, pointing right when `right`.
+fn arrow_glyph(ctx: &cairo::Context, x: f64, cy: f64, right: bool) {
+ let (tail, tip) = if right {
+ (x, x + SEGMENT_ICON_WIDTH)
+ } else {
+ (x + SEGMENT_ICON_WIDTH, x)
+ };
+ let back = if right { tip - 3.5 } else { tip + 3.5 };
+ round_line(ctx, (tail, cy), (tip, cy));
+ ctx.move_to(back, cy - 3.5);
+ ctx.line_to(tip, cy);
+ ctx.line_to(back, cy + 3.5);
+ let _ = ctx.stroke();
+}
+
+pub(super) fn draw_lock(
+ ctx: &cairo::Context,
+ rect: PanelRect,
+ lock: PropertiesPanelLock,
+ hovered: bool,
+) {
+ if hovered {
+ set_color(ctx, BG_HOVER);
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD);
+ let _ = ctx.fill();
+ }
+ let color = match lock {
+ PropertiesPanelLock::Locked => LOCK_ACTIVE,
+ PropertiesPanelLock::Partial => TEXT_HINT,
+ PropertiesPanelLock::Unlocked if hovered => TEXT_PRIMARY,
+ PropertiesPanelLock::Unlocked => TEXT_TERTIARY,
+ };
+ let (cx, cy) = rect.center();
+ set_color(ctx, color);
+ ctx.set_line_width(GLYPH_WIDTH);
+ ctx.set_line_cap(cairo::LineCap::Round);
+ draw_rounded_rect(ctx, cx - 5.0, cy - 0.5, 10.0, 7.0, 1.5);
+ let _ = ctx.stroke();
+ ctx.new_path();
+ ctx.move_to(cx - 2.6, cy - 0.5);
+ ctx.line_to(cx - 2.6, cy - 3.0);
+ if lock == PropertiesPanelLock::Unlocked {
+ // Open: the shackle swings up and stops short of the body.
+ ctx.arc(cx, cy - 3.0, 2.6, PI, PI * 1.85);
+ } else {
+ ctx.arc(cx, cy - 3.0, 2.6, PI, TAU);
+ ctx.line_to(cx + 2.6, cy - 0.5);
+ }
+ let _ = ctx.stroke();
+}
+
+pub(super) fn draw_swatches(
+ ctx: &cairo::Context,
+ swatches: &[PropertiesPanelSwatch],
+ rects: &[PanelRect],
+ more: PanelRect,
+ current: Option,
+ state: ControlState,
+) {
+ let background = chrome_rgb(PANEL_BG_PROPERTIES);
+ for (index, (swatch, rect)) in swatches.iter().zip(rects).enumerate() {
+ let (cx, cy) = rect.center();
+ let hovered = state.hovers(|hit| {
+ matches!(hit, PropertiesPanelHit::Swatch { index: hovered, .. } if hovered == index)
+ });
+ let radius = rect.width / 2.0 + if hovered { 1.0 } else { 0.0 };
+ let fill = state.fade(rgba(swatch.color));
+ let circle = |ctx: &cairo::Context| {
+ ctx.new_path();
+ ctx.arc(cx, cy, radius, 0.0, TAU);
+ };
+
+ // Translucency is the swatch's own: a locked row fades its swatches
+ // without turning them into checkerboards.
+ checkerboard_behind(ctx, swatch.color.a, circle);
+ circle(ctx);
+ set_color(ctx, fill);
+ let _ = ctx.fill_preserve();
+ let (edge, width) = swatch_edge_stroke(fill, background, SWATCH_HAIRLINE, 1.0);
+ set_color(ctx, edge);
+ ctx.set_line_width(width);
+ 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();
+ }
+ }
+
+ let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::MoreColors(_)));
+ let (cx, cy) = more.center();
+ let outline = if hovered {
+ ACCENT_BRIGHT
+ } else {
+ QUIET_OUTLINE
+ };
+ ctx.new_path();
+ ctx.arc(cx, cy, more.width / 2.0 - 0.5, 0.0, TAU);
+ set_color(ctx, state.fade(outline));
+ ctx.set_line_width(1.0);
+ ctx.set_dash(&[2.0, 2.0], 0.0);
+ let _ = ctx.stroke();
+ ctx.set_dash(&[], 0.0);
+ set_color(
+ ctx,
+ state.fade(if hovered { TEXT_PRIMARY } else { TEXT_HINT }),
+ );
+ plus_glyph(ctx, (cx, cy));
+}
+
+pub(super) fn draw_stepper(
+ engine: &UiTextEngine,
+ 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);
+ let _ = ctx.fill();
+
+ for (rect, is_up) in [(down, false), (up, true)] {
+ let hovered = state.hovers(|hit| match hit {
+ PropertiesPanelHit::StepUp(_) => is_up,
+ PropertiesPanelHit::StepDown(_) => !is_up,
+ _ => false,
+ });
+ if hovered {
+ set_color(ctx, BG_HOVER);
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD);
+ let _ = ctx.fill();
+ }
+ set_color(
+ ctx,
+ if !state.enabled {
+ TEXT_DISABLED
+ } else if hovered {
+ TEXT_WHITE
+ } else {
+ TEXT_SECONDARY
+ },
+ );
+ if is_up {
+ plus_glyph(ctx, rect.center());
+ } else {
+ minus_glyph(ctx, rect.center());
+ }
+ }
+
+ let style = text_style(VALUE_FONT, FontWeight::Normal);
+ let layout = engine.layout(ctx, style, text, None);
+ let extents = layout.ink_extents();
+ let (cx, cy) = value.center();
+ set_color(
+ ctx,
+ if state.enabled {
+ TEXT_PRIMARY
+ } else {
+ TEXT_DISABLED
+ },
+ );
+ layout.show_at_baseline(ctx, cx - extents.x_advance() / 2.0, cy + VALUE_FONT * 0.35);
+}
+
+pub(super) fn draw_switch(
+ ctx: &cairo::Context,
+ rect: PanelRect,
+ on: Option,
+ state: ControlState,
+) {
+ let hovered = state.hovers(|hit| {
+ matches!(
+ hit,
+ PropertiesPanelHit::Toggle(_) | PropertiesPanelHit::Row(_)
+ )
+ });
+ let radius = rect.height / 2.0;
+ let track = match on {
+ Some(true) => ACCENT_PRIMARY,
+ Some(false) => SWITCH_TRACK_OFF,
+ None => with_alpha(ACCENT_PRIMARY, 0.4),
+ };
+ set_color(ctx, state.fade(track));
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, radius);
+ let _ = ctx.fill_preserve();
+ if hovered {
+ set_color(ctx, with_alpha(ACCENT_BRIGHT, 0.6));
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+ }
+ ctx.new_path();
+
+ let knob = radius - 3.0;
+ let (_, cy) = rect.center();
+ match on {
+ Some(on) => {
+ let cx = if on {
+ rect.right() - 3.0 - knob
+ } else {
+ rect.x + 3.0 + knob
+ };
+ ctx.arc(cx, cy, knob, 0.0, TAU);
+ set_color(
+ ctx,
+ state.fade(if on { TEXT_WHITE } else { TEXT_SECONDARY }),
+ );
+ let _ = ctx.fill();
+ }
+ // Mixed: a bar in the middle, neither on nor off.
+ None => {
+ let (cx, _) = rect.center();
+ set_color(ctx, state.fade(TEXT_WHITE));
+ draw_rounded_rect(ctx, cx - 7.0, cy - 2.0, 14.0, 4.0, 2.0);
+ let _ = ctx.fill();
+ }
+ }
+}
+
+pub(super) fn draw_arrow_head_segments(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ well: PanelRect,
+ start: PanelRect,
+ end: PanelRect,
+ at_end: Option,
+ state: ControlState,
+) {
+ set_color(ctx, BG_HOVER_WASH);
+ draw_rounded_rect(ctx, well.x, well.y, well.width, well.height, RADIUS_STD);
+ let _ = ctx.fill();
+
+ let style = text_style(VALUE_FONT, FontWeight::Normal);
+ for (rect, is_end, label) in [(start, false, "Start"), (end, true, "End")] {
+ let active = at_end == Some(is_end);
+ let hovered = state.hovers(
+ |hit| matches!(hit, PropertiesPanelHit::ArrowHead { at_end, .. } if at_end == is_end),
+ );
+ if active {
+ set_color(ctx, state.fade(ACCENT_PRIMARY));
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_SM);
+ let _ = ctx.fill();
+ } else if hovered {
+ set_color(ctx, BG_HOVER_WASH);
+ draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_SM);
+ let _ = ctx.fill();
+ }
+
+ let text_width = engine
+ .layout(ctx, style, label, None)
+ .ink_extents()
+ .x_advance();
+ let content = text_width + SEGMENT_ICON_GAP + SEGMENT_ICON_WIDTH;
+ let (cx, cy) = rect.center();
+ let left = cx - content / 2.0;
+ let color = if !state.enabled {
+ TEXT_DISABLED
+ } else if active {
+ TEXT_WHITE
+ } else if hovered {
+ TEXT_PRIMARY
+ } else {
+ TEXT_HINT
+ };
+ set_color(ctx, color);
+ let (text_x, icon_x) = if is_end {
+ (left, left + text_width + SEGMENT_ICON_GAP)
+ } else {
+ (left + SEGMENT_ICON_WIDTH + SEGMENT_ICON_GAP, left)
+ };
+ engine.draw_baseline(ctx, style, label, text_x, cy + VALUE_FONT * 0.35, None);
+ arrow_glyph(ctx, icon_x, cy, is_end);
+ }
+}
+
+pub(super) fn draw_arrow_styles(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ buttons: &[(ArrowStyle, PanelRect)],
+ current: Option,
+ state: ControlState,
+) {
+ let caption = text_style(CAPTION_FONT, FontWeight::Normal);
+ for (style, rect) in buttons {
+ let active = current == Some(*style);
+ let hovered = state.hovers(|hit| {
+ matches!(hit, PropertiesPanelHit::ArrowStyle { style: hovered, .. } if hovered == *style)
+ });
+
+ draw_rounded_rect(
+ ctx,
+ rect.x + 0.5,
+ rect.y + 0.5,
+ rect.width - 1.0,
+ rect.height - 1.0,
+ RADIUS_MD,
+ );
+ set_color(
+ ctx,
+ if active {
+ state.fade(with_alpha(ACCENT_PRIMARY, 0.3))
+ } else {
+ BG_HOVER_WASH
+ },
+ );
+ let _ = ctx.fill_preserve();
+ let outline = if active {
+ ACCENT_BRIGHT
+ } else if hovered {
+ with_alpha(ACCENT_BRIGHT, 0.6)
+ } else {
+ QUIET_OUTLINE
+ };
+ set_color(ctx, state.fade(outline));
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+
+ let ink = state.fade(if active || hovered {
+ TEXT_WHITE
+ } else {
+ TEXT_SECONDARY
+ });
+ draw_arrow_style_preview(
+ ctx,
+ (rect.x + 6.0, rect.y + 5.0, rect.width - 12.0, 18.0),
+ *style,
+ ink,
+ );
+
+ let label = style.label();
+ let width = engine
+ .layout(ctx, caption, label, None)
+ .ink_extents()
+ .x_advance();
+ set_color(ctx, ink);
+ engine.draw_baseline(
+ ctx,
+ caption,
+ label,
+ rect.x + (rect.width - width) / 2.0,
+ rect.bottom() - 8.0,
+ None,
+ );
+ }
+}
diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs
index 0c6214235..236e76312 100644
--- a/src/ui/toolbar/model/style_pill.rs
+++ b/src/ui/toolbar/model/style_pill.rs
@@ -288,7 +288,7 @@ impl StylePillSpec {
// swatches (the color chip stays either way). Hiding the item
// hides the swatch row.
if toolbar_item_visible(snapshot, ids::TOP_GROUP_QUICK_COLORS) {
- // Swatches follow the strip's width-degradation plan (8→6→4→0)
+ // Swatches follow the strip's width-degradation plan (12→8→6→4→0)
// so the pill narrows with the islands above it.
let count = snapshot
.quick_colors
diff --git a/src/ui/toolbar/model/style_pill/tests/selection.rs b/src/ui/toolbar/model/style_pill/tests/selection.rs
index 0be1a782f..73916d962 100644
--- a/src/ui/toolbar/model/style_pill/tests/selection.rs
+++ b/src/ui/toolbar/model/style_pill/tests/selection.rs
@@ -10,6 +10,7 @@ fn selection_entry(
label: label.to_string(),
value: value.to_string(),
kind,
+ state: kind.unknown_value(),
disabled,
}
}
diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
index 0e91aab4f..b7e8d3cc2 100644
--- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs
+++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
@@ -440,6 +440,22 @@ fn minimized_and_micro_strips_hide_the_pill() {
assert!(StylePillSpec::build(µ, &plan()).controls().is_empty());
}
+#[test]
+fn an_unconstrained_pill_offers_the_whole_built_in_palette() {
+ let snapshot = snapshot_for_tool(Tool::Pen);
+ let palette = crate::config::QuickColorPalette::default();
+ assert!(palette.rendered_entries().len() <= StylePillSpec::MAX_SWATCHES);
+
+ let spec = StylePillSpec::build(&snapshot, &plan());
+
+ let swatches = spec
+ .controls()
+ .iter()
+ .filter(|control| matches!(control, StylePillControl::QuickSwatch(_)))
+ .count();
+ assert_eq!(swatches, palette.rendered_entries().len());
+}
+
#[test]
fn stroke_state_orders_chip_swatches_slider_and_numeral() {
let snapshot = snapshot_for_tool(Tool::Pen);
@@ -898,6 +914,7 @@ fn the_docked_selection_control_reports_on_the_selected_shape_not_the_tool_defau
label: "Magnification".to_string(),
value: "3x".to_string(),
kind: crate::input::SelectionPropertyKind::SpotlightMagnification,
+ state: crate::input::SelectionPropertyValue::Number(Some(3.0)),
disabled: false,
});
diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs
index 4a79f4c8d..6e0d9ff35 100644
--- a/src/ui/toolbar/model/top_spec/spec.rs
+++ b/src/ui/toolbar/model/top_spec/spec.rs
@@ -30,7 +30,9 @@ pub(crate) struct TopStripPlan {
}
impl TopStripPlan {
- pub(crate) const MAX_QUICK_COLORS: usize = 8;
+ /// Room for the whole built-in palette (11 colors); a longer custom
+ /// palette keeps the rest in the color picker, as a narrow strip does.
+ pub(crate) const MAX_QUICK_COLORS: usize = 12;
pub(crate) fn unconstrained() -> Self {
Self {