diff --git a/README.md b/README.md
index 50610bf26..78ad25a04 100644
--- a/README.md
+++ b/README.md
@@ -119,12 +119,12 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
- Freehand pen, highlighter, eraser (circle/rect)
- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink; a brief chip such as "Circle · Ctrl+Z keeps ink" names each recognized shape (`[drawing] shape_recognition_feedback`). Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition in the style pill's **Pen feel** panel (or its **Shapes** meter or **Detect** stepper, per `[ui.toolbar] stroke_controls`), `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session.
- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the style pill's **Pen feel** chip, which opens a panel with a smoothing meter and a live preview of the result; `[ui.toolbar] stroke_controls` swaps it for inline **Smooth** meters or steppers); tablet pressure values are preserved, and the level is remembered with the session
-- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
+- Shapes: lines, rectangles, ellipses, polygons (with fill toggle; the properties panel gives a fill its own color)
- Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs
- Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out
- Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification
- Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color)
-- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, and a lock toggle; scroll over a row to step it, and hover the title for the shape's ID and creation time
+- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; a preset row applies a tool preset's style to the selection (keeping the current tool), and **Save** then a slot stores the selection's style as that preset; scroll over a row to step it, and hover the title for the shape's ID and creation time
- Duplicate (Ctrl+D), delete (Delete), undo/redo
- Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll
- Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text
diff --git a/config.example.toml b/config.example.toml
index 2672bcf0a..a93038183 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1356,6 +1356,9 @@ size = 3.0
# Optional overrides
marker_opacity = 0.32
fill_enabled = false
+# Optional fill color, when a fill differs from `color` (written when a
+# preset is saved from a shape filled with its own color)
+# fill_color = "blue"
font_size = 32.0
text_background_enabled = false
arrow_length = 20.0
diff --git a/configurator/src/models/config/presets.rs b/configurator/src/models/config/presets.rs
index c4638f8f7..ca165e00e 100644
--- a/configurator/src/models/config/presets.rs
+++ b/configurator/src/models/config/presets.rs
@@ -6,7 +6,7 @@ use super::super::fields::{
use super::super::util::format_float;
use super::parse::{parse_optional_f64, parse_required_f64};
use wayscriber::config::{
- Config, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig,
+ ColorSpec, Config, MouseDragToolsConfig, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PresetSlotsConfig,
PresetToolStatesConfig, ToolPresetConfig,
};
use wayscriber::domain::Tool;
@@ -22,6 +22,9 @@ pub struct PresetSlotDraft {
pub eraser_mode: PresetEraserModeOption,
pub marker_opacity: String,
pub fill_enabled: OverrideOption,
+ /// Carried through unedited: set when a preset is saved from a shape
+ /// filled with its own color, so editing the slot here keeps it.
+ pub fill_color: Option,
pub font_size: String,
pub text_background_enabled: OverrideOption,
pub arrow_length: String,
@@ -46,6 +49,7 @@ impl PresetSlotDraft {
eraser_mode: PresetEraserModeOption::from_option(preset.eraser_mode),
marker_opacity: preset.marker_opacity.map(format_float).unwrap_or_default(),
fill_enabled: OverrideOption::from_option(preset.fill_enabled),
+ fill_color: preset.fill_color.clone(),
font_size: preset.font_size.map(format_float).unwrap_or_default(),
text_background_enabled: OverrideOption::from_option(
preset.text_background_enabled,
@@ -77,6 +81,7 @@ impl PresetSlotDraft {
eraser_mode: PresetEraserModeOption::Default,
marker_opacity: String::new(),
fill_enabled: OverrideOption::Default,
+ fill_color: None,
font_size: String::new(),
text_background_enabled: OverrideOption::Default,
arrow_length: String::new(),
@@ -174,6 +179,7 @@ impl PresetSlotDraft {
eraser_mode: self.eraser_mode.to_option(),
marker_opacity,
fill_enabled: self.fill_enabled.to_option(),
+ fill_color: self.fill_color.clone(),
font_size,
text_background_enabled: self.text_background_enabled.to_option(),
arrow_length,
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index 596c3199c..74e9d7b24 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -1398,6 +1398,7 @@ fn config_draft_round_trips_presets_and_history() {
eraser_mode: None,
marker_opacity: Some(0.5),
fill_enabled: Some(true),
+ fill_color: Some(ColorSpec::Name("green".to_string())),
font_size: Some(14.0),
text_background_enabled: Some(false),
arrow_length: Some(20.0),
@@ -1467,6 +1468,7 @@ fn preset_tool_change_loads_selected_tool_profile_values() {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
@@ -1525,6 +1527,7 @@ fn preset_visible_edits_update_selected_tool_profile_only() {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 568a095bc..05b0809a9 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -762,12 +762,15 @@ size = 28.0
```
**Required fields:** `tool`, `color`, `size`
-**Optional fields:** `tool_settings`, `eraser_kind`, `eraser_mode`, `marker_opacity`, `fill_enabled`, `font_size`, `text_background_enabled`, `arrow_length`, `arrow_angle`, `arrow_head_at_end`, `polygon_sides`, `show_status_bar`, `drag_tools`
+**Optional fields:** `tool_settings`, `eraser_kind`, `eraser_mode`, `marker_opacity`, `fill_enabled`, `fill_color`, `font_size`, `text_background_enabled`, `arrow_length`, `arrow_angle`, `arrow_head_at_end`, `polygon_sides`, `show_status_bar`, `drag_tools`
When `tool_settings` is present, applying the preset restores the full drawing profile for all
tools, including StepMarker size and Eraser size, then activates `tool`. Legacy presets without
`tool_settings` keep the old behavior and apply only `color`/`size` to the selected `tool`.
The top-level `color` and `size` are retained for compatibility, readability, and toolbar previews.
+`fill_color` is written when a preset is saved from the properties panel off a rectangle, ellipse,
+or polygon filled with its own color; applying the preset to selected closed shapes restores that
+fill. Without it, a preset's fill takes its border color.
### `[history]` - Undo/Redo Playback
diff --git a/src/backend/wayland/clipboard/transfer/tests.rs b/src/backend/wayland/clipboard/transfer/tests.rs
index c7110914b..5866d0927 100644
--- a/src/backend/wayland/clipboard/transfer/tests.rs
+++ b/src/backend/wayland/clipboard/transfer/tests.rs
@@ -222,6 +222,7 @@ fn rect() -> Shape {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: crate::draw::BLACK,
thick: 1.0,
}
diff --git a/src/backend/wayland/config_edits/tests.rs b/src/backend/wayland/config_edits/tests.rs
index 70b14e341..abc942d62 100644
--- a/src/backend/wayland/config_edits/tests.rs
+++ b/src/backend/wayland/config_edits/tests.rs
@@ -39,6 +39,7 @@ fn preset(name: &str) -> Box {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs
index 9f2813c95..8e3172296 100644
--- a/src/backend/wayland/session/tests.rs
+++ b/src/backend/wayland/session/tests.rs
@@ -1447,6 +1447,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: input.style.current_color,
thick: input.style.current_thickness,
});
diff --git a/src/backend/wayland/state/clipboard/session_paste/tests.rs b/src/backend/wayland/state/clipboard/session_paste/tests.rs
index d00a474ff..104e937cc 100644
--- a/src/backend/wayland/state/clipboard/session_paste/tests.rs
+++ b/src/backend/wayland/state/clipboard/session_paste/tests.rs
@@ -59,6 +59,7 @@ fn paste_preflight_skips_stale_target_page_generation() {
w: 3,
h: 4,
fill: false,
+ fill_color: None,
color: Color {
r: 1.0,
g: 0.0,
diff --git a/src/backend/wayland/state/onboarding/first_run_card.rs b/src/backend/wayland/state/onboarding/first_run_card.rs
index a2cfdb196..a6b8cf072 100644
--- a/src/backend/wayland/state/onboarding/first_run_card.rs
+++ b/src/backend/wayland/state/onboarding/first_run_card.rs
@@ -66,7 +66,10 @@ impl WaylandState {
FirstRunStep::QuickAccess => OnboardingCard {
eyebrow,
title: "Quick access at cursor".to_string(),
- body: "Open quick actions near the pointer.".to_string(),
+ // Alt+click is a fixed mouse gesture, not a binding.
+ body: "Open quick actions near the pointer. Alt+click a shape to select it \
+ without switching tools."
+ .to_string(),
items: self.quick_access_checklist_items(state),
buttons: vec![skip_tour_button()],
footer: String::new(),
diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs
index 776c730d7..cdafa84e5 100644
--- a/src/backend/wayland/state/pdf_export/tests.rs
+++ b/src/backend/wayland/state/pdf_export/tests.rs
@@ -156,6 +156,7 @@ fn fit_content_snapshot_uses_content_bounds() {
w: 100,
h: 50,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/backend/wayland/state/region_capture/cut_preview/tests.rs b/src/backend/wayland/state/region_capture/cut_preview/tests.rs
index df89d9e54..4619f87d1 100644
--- a/src/backend/wayland/state/region_capture/cut_preview/tests.rs
+++ b/src/backend/wayland/state/region_capture/cut_preview/tests.rs
@@ -907,6 +907,7 @@ fn render_source_jobs_paint_annotations_before_applying_key_cuts_on_the_worker()
w: 8,
h: 8,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/backend/wayland/state/region_capture/render.rs b/src/backend/wayland/state/region_capture/render.rs
index 790337919..9f634c480 100644
--- a/src/backend/wayland/state/region_capture/render.rs
+++ b/src/backend/wayland/state/region_capture/render.rs
@@ -165,6 +165,7 @@ mod tests {
w: 3,
h: 2,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs
index bdb872b8c..0f439248f 100644
--- a/src/backend/wayland/state/region_capture/tests/picker.rs
+++ b/src/backend/wayland/state/region_capture/tests/picker.rs
@@ -135,6 +135,7 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() {
w: 3,
h: 2,
fill: true,
+ fill_color: None,
color: crate::draw::RED,
thick: 1.0,
});
diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs
index c7272214f..addad1218 100644
--- a/src/backend/wayland/state/render/canvas/resource_tests.rs
+++ b/src/backend/wayland/state/render/canvas/resource_tests.rs
@@ -199,6 +199,7 @@ fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() {
},
thick: 2.0,
fill: true,
+ fill_color: None,
});
}
assert!(layer.ensure(&measurer, &mut caches, &shapes, request));
@@ -327,6 +328,7 @@ fn each_scene_key_rebakes_without_shape_identity_changes() {
},
thick: 2.0,
fill: true,
+ fill_color: None,
});
}
assert!(layer.ensure(&measurer, &mut caches, &scene, changed));
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 13225c9ae..586b82049 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -653,6 +653,7 @@ impl WaylandState {
self.input_state.ui_visibility.show_tool_preview
&& self.has_cursor_focus()
&& !self.cursor_blocked_by_toolbar()
+ && !self.input_state.canvas_press_dismisses_popup()
&& matches!(
self.input_state.state,
DrawingState::Idle | DrawingState::PendingTextClick { .. }
diff --git a/src/backend/wayland/state/toolbar/events/presets.rs b/src/backend/wayland/state/toolbar/events/presets.rs
index 366591a7d..cf3608962 100644
--- a/src/backend/wayland/state/toolbar/events/presets.rs
+++ b/src/backend/wayland/state/toolbar/events/presets.rs
@@ -155,6 +155,7 @@ mod tests {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs
index c83958426..6fd95e1fa 100644
--- a/src/canvas_export/mod.rs
+++ b/src/canvas_export/mod.rs
@@ -88,6 +88,7 @@ mod tests {
w: 8,
h: 8,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -115,6 +116,7 @@ mod tests {
w: 4,
h: 4,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -189,6 +191,7 @@ mod tests {
w: 2,
h: 2,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -351,6 +354,7 @@ mod tests {
w: 2,
h: 4,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -468,6 +472,7 @@ mod tests {
w: 6,
h: 6,
fill: true,
+ fill_color: None,
color: BLACK,
thick: 1.0,
});
@@ -506,6 +511,7 @@ mod tests {
w: 12,
h: 12,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -541,6 +547,7 @@ mod tests {
w: 12,
h: 12,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs
index 06aaaf5a4..80c6dfc4d 100644
--- a/src/canvas_export/region.rs
+++ b/src/canvas_export/region.rs
@@ -355,6 +355,7 @@ mod tests {
w: 1,
h: 1,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -364,6 +365,7 @@ mod tests {
w: 10,
h: 10,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -392,6 +394,7 @@ mod tests {
w: 1,
h: 1,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -550,6 +553,7 @@ mod tests {
w: 4,
h: 4,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -654,6 +658,7 @@ mod tests {
w: 1,
h: 1,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -690,6 +695,7 @@ mod tests {
w: 8,
h: 1,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/canvas_export/tests/board_grid.rs b/src/canvas_export/tests/board_grid.rs
index c4a3d0e81..051a6c6fb 100644
--- a/src/canvas_export/tests/board_grid.rs
+++ b/src/canvas_export/tests/board_grid.rs
@@ -34,6 +34,7 @@ fn board_grid_png_erasers_restore_pattern_and_snapshot_is_independent() {
w: 20,
h: 20,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
@@ -142,6 +143,7 @@ fn board_grid_pdf_stays_vector_without_erasers_and_leaves_margins_plain() {
w: 8,
h: 8,
fill: true,
+ fill_color: None,
color: RED,
thick: 1.0,
});
diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs
index 3d08da54a..844b16639 100644
--- a/src/config/tests/validate.rs
+++ b/src/config/tests/validate.rs
@@ -526,6 +526,7 @@ fn validate_clamps_preset_fields() {
eraser_mode: None,
marker_opacity: Some(1.2),
fill_enabled: None,
+ fill_color: None,
font_size: Some(2.0),
text_background_enabled: None,
arrow_length: Some(100.0),
diff --git a/src/config/tests/write_lock.rs b/src/config/tests/write_lock.rs
index 2eeb66090..6784fa5ed 100644
--- a/src/config/tests/write_lock.rs
+++ b/src/config/tests/write_lock.rs
@@ -50,6 +50,7 @@ fn preset(name: &str) -> ToolPresetConfig {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
diff --git a/src/config/tests/write_target.rs b/src/config/tests/write_target.rs
index 1c19ff37c..b75696cdb 100644
--- a/src/config/tests/write_target.rs
+++ b/src/config/tests/write_target.rs
@@ -61,6 +61,7 @@ fn preset(name: &str) -> ToolPresetConfig {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
@@ -834,3 +835,30 @@ fn an_edit_that_waits_for_the_lock_reapplies_onto_the_retargeted_file() {
"and the file the path used to name keeps every byte"
);
}
+
+/// A preset saved from a shape filled with its own color keeps that fill
+/// through the file, and one without leaves no trace of the field.
+#[test]
+fn a_preset_keeps_its_fill_color_through_the_config_file() {
+ let temp = crate::test_temp::tempdir().expect("tempdir");
+ let path = temp.path().join("config.toml");
+ fs::write(&path, ORIGINAL).expect("seed config");
+ let mut filled = preset("Filled");
+ filled.tool = crate::input::Tool::Rect;
+ filled.fill_enabled = Some(true);
+ filled.fill_color = Some(ColorSpec::from(Color {
+ r: 0.0,
+ g: 0.8,
+ b: 0.4,
+ a: 1.0,
+ }));
+
+ persist_preset_slot_at(&path, 1, Some(&filled)).expect("write filled preset");
+ persist_preset_slot_at(&path, 2, Some(&preset("Plain"))).expect("write plain preset");
+
+ let written = fs::read_to_string(&path).expect("readable");
+ assert_eq!(written.matches("fill_color").count(), 1, "{written}");
+ let loaded: super::super::Config = toml::from_str(&written).expect("parse");
+ assert_eq!(loaded.presets.get_slot(1), Some(&filled));
+ assert_eq!(loaded.presets.get_slot(2), Some(&preset("Plain")));
+}
diff --git a/src/config/types/presets.rs b/src/config/types/presets.rs
index cff91baf2..7c8bd08ef 100644
--- a/src/config/types/presets.rs
+++ b/src/config/types/presets.rs
@@ -46,6 +46,13 @@ pub struct ToolPresetConfig {
#[serde(default)]
pub fill_enabled: Option,
+ /// Optional fill color, for a fill that differs from `color`. Set when a
+ /// preset is saved from a shape filled with its own color; applying the
+ /// preset to selected closed shapes restores it. Absent, a fill takes the
+ /// border color.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fill_color: Option,
+
/// Optional font size override.
#[serde(default)]
pub font_size: Option,
diff --git a/src/draw/dirty.rs b/src/draw/dirty.rs
index 8d23793c2..d31526f54 100644
--- a/src/draw/dirty.rs
+++ b/src/draw/dirty.rs
@@ -162,6 +162,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: Color {
r: 1.0,
g: 0.0,
@@ -177,6 +178,7 @@ mod tests {
w: 15,
h: 15,
fill: false,
+ fill_color: None,
color: Color {
r: 0.0,
g: 1.0,
diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs
index 8bef0a299..dbaac9678 100644
--- a/src/draw/frame/core.rs
+++ b/src/draw/frame/core.rs
@@ -240,7 +240,10 @@ impl Frame {
Some(shape)
}
- /// Moves a shape from one index to another.
+ /// Moves a shape from one index to another; `to` is the index it ends at.
+ ///
+ /// It used to be read as "insert before the shape at `to`", which cannot
+ /// reach the top: Move to Front left a shape one below it.
pub fn move_shape(&mut self, from: usize, to: usize) -> Option<()> {
if from >= self.shapes.len() || to >= self.shapes.len() {
return None;
@@ -249,11 +252,7 @@ impl Frame {
return Some(());
}
let shape = self.shapes.remove(from);
- let mut insert_index = to.min(self.shapes.len());
- if from < to && insert_index > 0 {
- insert_index -= 1;
- }
- self.shapes.insert(insert_index, shape);
+ self.shapes.insert(to, shape);
self.bump_shape_order_generation();
Some(())
}
diff --git a/src/draw/frame/history/frame/apply.rs b/src/draw/frame/history/frame/apply.rs
index eef8342db..422bdcaa8 100644
--- a/src/draw/frame/history/frame/apply.rs
+++ b/src/draw/frame/history/frame/apply.rs
@@ -87,12 +87,11 @@ impl Frame {
if index == target {
return;
}
+ // `target` is the index the shape ends at, as `Frame::move_shape`
+ // recorded it.
let shape = self.shapes.remove(index);
- let mut insert_index = target.min(self.shapes.len());
- if index < insert_index && insert_index > 0 {
- insert_index -= 1;
- }
- self.shapes.insert(insert_index, shape);
+ let target = target.min(self.shapes.len());
+ self.shapes.insert(target, shape);
self.bump_shape_order_generation();
}
}
diff --git a/src/draw/frame/tests/history/basics.rs b/src/draw/frame/tests/history/basics.rs
index 64276ff73..4161e5593 100644
--- a/src/draw/frame/tests/history/basics.rs
+++ b/src/draw/frame/tests/history/basics.rs
@@ -8,6 +8,7 @@ fn rect_at(x: i32) -> Shape {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
}
@@ -118,6 +119,7 @@ fn adding_new_shape_clears_redo_stack() {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
};
@@ -140,6 +142,7 @@ fn adding_new_shape_clears_redo_stack() {
w: 15,
h: 15,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
};
diff --git a/src/draw/frame/tests/history/prune.rs b/src/draw/frame/tests/history/prune.rs
index 0546264ca..c50e5c070 100644
--- a/src/draw/frame/tests/history/prune.rs
+++ b/src/draw/frame/tests/history/prune.rs
@@ -11,6 +11,7 @@ fn prune_history_for_removed_ids_prunes_shapes_and_actions() {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 1.0,
};
diff --git a/src/draw/render/primitives.rs b/src/draw/render/primitives.rs
index 0f97648bb..8c2a502b2 100644
--- a/src/draw/render/primitives.rs
+++ b/src/draw/render/primitives.rs
@@ -20,7 +20,18 @@ pub(super) fn render_line(
let _ = ctx.stroke();
}
-/// Render a rectangle (outline)
+/// Fill the current path with `fill`, if any, and keep the path for the
+/// outline stroke that follows.
+fn fill_path(ctx: &cairo::Context, fill: Option) {
+ if let Some(paint) = fill {
+ let _ = ctx.save();
+ ctx.set_source_rgba(paint.r, paint.g, paint.b, paint.a);
+ let _ = ctx.fill_preserve();
+ let _ = ctx.restore();
+ }
+}
+
+/// Render a rectangle outline, filled with `fill` when there is one.
#[allow(clippy::too_many_arguments)]
pub(super) fn render_rect(
ctx: &cairo::Context,
@@ -28,7 +39,7 @@ pub(super) fn render_rect(
y: i32,
w: i32,
h: i32,
- fill: bool,
+ fill: Option,
color: Color,
thick: f64,
) {
@@ -41,12 +52,7 @@ pub(super) fn render_rect(
let (norm_x, norm_y, norm_w, norm_h) = util::normalize_i32_rect(x, y, w, h);
ctx.rectangle(norm_x, norm_y, norm_w, norm_h);
- if fill {
- let _ = ctx.save();
- ctx.set_source_rgba(color.r, color.g, color.b, color.a);
- let _ = ctx.fill_preserve();
- let _ = ctx.restore();
- }
+ fill_path(ctx, fill);
let _ = ctx.stroke();
}
@@ -58,7 +64,7 @@ pub(super) fn render_ellipse(
cy: i32,
rx: i32,
ry: i32,
- fill: bool,
+ fill: Option,
color: Color,
thick: f64,
) {
@@ -74,12 +80,7 @@ pub(super) fn render_ellipse(
ctx.scale(rx as f64, ry as f64);
ctx.new_sub_path();
ctx.arc(0.0, 0.0, 1.0, 0.0, 2.0 * std::f64::consts::PI);
- if fill {
- let _ = ctx.save();
- ctx.set_source_rgba(color.r, color.g, color.b, color.a);
- let _ = ctx.fill_preserve();
- ctx.restore().ok();
- }
+ fill_path(ctx, fill);
ctx.restore().ok();
let _ = ctx.stroke();
@@ -90,7 +91,7 @@ pub(super) fn render_ellipse(
pub(super) fn render_polygon(
ctx: &cairo::Context,
points: &[(i32, i32)],
- fill: bool,
+ fill: Option,
color: Color,
thick: f64,
) {
@@ -109,9 +110,7 @@ pub(super) fn render_polygon(
ctx.line_to(x as f64, y as f64);
}
ctx.close_path();
- if fill {
- let _ = ctx.fill_preserve();
- }
+ fill_path(ctx, fill);
let _ = ctx.stroke();
let _ = ctx.restore();
}
@@ -515,7 +514,7 @@ mod tests {
};
ctx.move_to(10.0, 90.0);
- render_ellipse(&ctx, 80, 20, 20, 10, false, magenta, 6.0);
+ render_ellipse(&ctx, 80, 20, 20, 10, None, magenta, 6.0);
drop(ctx);
assert_eq!(
@@ -545,9 +544,49 @@ mod tests {
i32::MIN,
i32::MIN,
i32::MIN,
- true,
+ Some(color),
color,
1.0,
);
}
+
+ fn rgb_at(surface: &mut ImageSurface, x: i32, y: i32) -> (u8, u8, u8) {
+ let stride = surface.stride() as usize;
+ let offset = y as usize * stride + x as usize * 4;
+ let data = surface.data().unwrap();
+ // ARGB32 is stored little-endian as B, G, R, A.
+ (data[offset + 2], data[offset + 1], data[offset])
+ }
+
+ #[test]
+ fn a_fill_color_paints_the_inside_and_the_border_keeps_its_own() {
+ let (mut surface, ctx) = surface_with_context(60, 60);
+ let red = Color::new(1.0, 0.0, 0.0, 1.0);
+ let blue = Color::new(0.0, 0.0, 1.0, 1.0);
+ crate::draw::render_shape(
+ &ctx,
+ &crate::draw::Shape::Rect {
+ x: 10,
+ y: 10,
+ w: 40,
+ h: 40,
+ fill: true,
+ fill_color: Some(blue),
+ color: red,
+ thick: 4.0,
+ },
+ );
+
+ drop(ctx);
+ assert_eq!(
+ rgb_at(&mut surface, 30, 30),
+ (0, 0, 255),
+ "inside is the fill"
+ );
+ assert_eq!(
+ rgb_at(&mut surface, 10, 30),
+ (255, 0, 0),
+ "the border is red"
+ );
+ }
}
diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs
index 5d29b476f..807d8b275 100644
--- a/src/draw/render/selection.rs
+++ b/src/draw/render/selection.rs
@@ -71,7 +71,16 @@ pub fn render_selection_halo_with_measurer(
fill,
..
} => {
- render_rect(ctx, *x, *y, *w, *h, *fill, glow, thick + outline_width);
+ render_rect(
+ ctx,
+ *x,
+ *y,
+ *w,
+ *h,
+ fill.then_some(glow),
+ glow,
+ thick + outline_width,
+ );
}
Shape::Ellipse {
cx,
@@ -82,7 +91,16 @@ pub fn render_selection_halo_with_measurer(
thick,
..
} => {
- render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, glow, thick + outline_width);
+ render_ellipse(
+ ctx,
+ *cx,
+ *cy,
+ *rx,
+ *ry,
+ fill.then_some(glow),
+ glow,
+ thick + outline_width,
+ );
}
Shape::Spotlight { cx, cy, rx, ry, .. } => {
// The spotlight itself paints nothing, so the halo is the only way to
@@ -101,7 +119,7 @@ pub fn render_selection_halo_with_measurer(
);
}
Shape::Polygon { points, thick, .. } => {
- render_polygon(ctx, points, false, glow, thick + outline_width);
+ render_polygon(ctx, points, None, glow, thick + outline_width);
}
Shape::Arrow {
x1,
diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs
index ce13a0ef7..66ffe536f 100644
--- a/src/draw/render/shapes.rs
+++ b/src/draw/render/shapes.rs
@@ -121,10 +121,12 @@ pub(super) fn render_shape_with_cache(
w,
h,
fill,
+ fill_color,
color,
thick,
} => {
- render_rect(ctx, *x, *y, *w, *h, *fill, *color, *thick);
+ let paint = fill.then(|| fill_color.unwrap_or(*color));
+ render_rect(ctx, *x, *y, *w, *h, paint, *color, *thick);
}
Shape::Ellipse {
cx,
@@ -132,19 +134,23 @@ pub(super) fn render_shape_with_cache(
rx,
ry,
fill,
+ fill_color,
color,
thick,
} => {
- render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, *color, *thick);
+ let paint = fill.then(|| fill_color.unwrap_or(*color));
+ render_ellipse(ctx, *cx, *cy, *rx, *ry, paint, *color, *thick);
}
Shape::Polygon {
points,
fill,
+ fill_color,
color,
thick,
..
} => {
- render_polygon(ctx, points, *fill, *color, *thick);
+ let paint = fill.then(|| fill_color.unwrap_or(*color));
+ render_polygon(ctx, points, paint, *color, *thick);
}
Shape::Arrow {
x1,
diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs
index 68ea653d3..da8739fa9 100644
--- a/src/draw/shape/tests.rs
+++ b/src/draw/shape/tests.rs
@@ -241,6 +241,7 @@ fn ellipse_bounding_box_handles_radii_and_stroke() {
rx: 40,
ry: 20,
fill: false,
+ fill_color: None,
color: WHITE,
thick: 2.0,
};
@@ -289,6 +290,7 @@ fn polygon_bounding_box_covers_vertices_and_stroke() {
kind: PolygonKind::Triangle,
points: vec![(10, 20), (30, 40), (5, 35)],
fill: false,
+ fill_color: None,
color: WHITE,
thick: 6.0,
};
@@ -306,6 +308,7 @@ fn polygon_shape_serializes_and_deserializes_with_points() {
kind: PolygonKind::Regular { sides: 6 },
points: vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)],
fill: true,
+ fill_color: None,
color: WHITE,
thick: 4.0,
};
@@ -318,10 +321,12 @@ fn polygon_shape_serializes_and_deserializes_with_points() {
kind,
points,
fill,
+ fill_color,
color,
thick,
} => {
assert_eq!(kind, PolygonKind::Regular { sides: 6 });
+ assert_eq!(fill_color, None);
assert_eq!(
points,
vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)]
@@ -340,6 +345,7 @@ fn invalid_polygon_has_no_bounds() {
kind: PolygonKind::Freeform,
points: vec![(10, 20), (10, 20), (30, 40)],
fill: false,
+ fill_color: None,
color: WHITE,
thick: 6.0,
};
@@ -631,3 +637,43 @@ fn embedded_image_clones_share_the_encoded_payload() {
assert!(std::sync::Arc::ptr_eq(&image.bytes, &cloned.bytes));
}
+
+#[test]
+fn a_fill_color_is_written_only_when_set_and_old_files_read_without_one() {
+ let old = r#"{"Rect":{"x":1,"y":2,"w":30,"h":40,"fill":true,"color":{"r":1.0,"g":0.0,"b":0.0,"a":1.0},"thick":2.0}}"#;
+ let shape: Shape = serde_json::from_str(old).expect("a rect saved before fill colors");
+ match &shape {
+ Shape::Rect {
+ fill, fill_color, ..
+ } => {
+ assert!(*fill);
+ assert_eq!(*fill_color, None);
+ }
+ other => panic!("expected rect, got {other:?}"),
+ }
+ let json = serde_json::to_string(&shape).expect("serialize");
+ assert!(
+ !json.contains("fill_color"),
+ "an unset fill color stays out: {json}"
+ );
+
+ let blue = crate::draw::Color {
+ r: 0.0,
+ g: 0.0,
+ b: 1.0,
+ a: 1.0,
+ };
+ let filled = Shape::Ellipse {
+ cx: 10,
+ cy: 10,
+ rx: 5,
+ ry: 5,
+ fill: true,
+ fill_color: Some(blue),
+ color: WHITE,
+ thick: 2.0,
+ };
+ let restored: Shape =
+ serde_json::from_str(&serde_json::to_string(&filled).expect("serialize")).expect("read");
+ assert_eq!(restored, filled);
+}
diff --git a/src/draw/shape/transform.rs b/src/draw/shape/transform.rs
index ffce4908f..507a52238 100644
--- a/src/draw/shape/transform.rs
+++ b/src/draw/shape/transform.rs
@@ -90,6 +90,7 @@ impl Shape {
w,
h,
fill,
+ fill_color,
color,
thick,
} => {
@@ -102,6 +103,7 @@ impl Shape {
w: nw.max(1),
h: nh.max(1),
fill: *fill,
+ fill_color: *fill_color,
color: *color,
thick: *thick,
}
@@ -112,6 +114,7 @@ impl Shape {
rx,
ry,
fill,
+ fill_color,
color,
thick,
} => {
@@ -124,6 +127,7 @@ impl Shape {
rx: nrx.max(1),
ry: nry.max(1),
fill: *fill,
+ fill_color: *fill_color,
color: *color,
thick: *thick,
}
@@ -210,6 +214,7 @@ impl Shape {
kind,
points,
fill,
+ fill_color,
color,
thick,
} => {
@@ -218,6 +223,7 @@ impl Shape {
kind: *kind,
points: scaled_points,
fill: *fill,
+ fill_color: *fill_color,
color: *color,
thick: *thick,
}
diff --git a/src/draw/shape/transform/tests.rs b/src/draw/shape/transform/tests.rs
index 1bfd245c4..51cd932fc 100644
--- a/src/draw/shape/transform/tests.rs
+++ b/src/draw/shape/transform/tests.rs
@@ -46,6 +46,7 @@ fn every_shape_variant_translates_its_bounds() {
w: 30,
h: 40,
fill: false,
+ fill_color: None,
color: WHITE,
thick: 2.0,
},
@@ -55,6 +56,7 @@ fn every_shape_variant_translates_its_bounds() {
rx: 10,
ry: 15,
fill: false,
+ fill_color: None,
color: WHITE,
thick: 2.0,
},
@@ -62,6 +64,7 @@ fn every_shape_variant_translates_its_bounds() {
kind: PolygonKind::Freeform,
points: vec![(10, 20), (30, 20), (20, 40)],
fill: true,
+ fill_color: None,
color: WHITE,
thick: 2.0,
},
diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs
index da0d451c9..f6f360ae2 100644
--- a/src/draw/shape/types.rs
+++ b/src/draw/shape/types.rs
@@ -238,6 +238,12 @@ pub enum Shape {
h: i32,
/// Whether to fill the rectangle
fill: bool,
+ /// Fill paint while `fill` is on. `None` fills with the border color,
+ /// as every fill did before fills had their own color; it is only
+ /// written when set, so older sessions and newer ones read the same.
+ /// Kept while `fill` is off, so turning the fill back on restores it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ fill_color: Option,
/// Border color
color: Color,
/// Border thickness in pixels
@@ -255,6 +261,12 @@ pub enum Shape {
ry: i32,
/// Whether to fill the ellipse
fill: bool,
+ /// Fill paint while `fill` is on. `None` fills with the border color,
+ /// as every fill did before fills had their own color; it is only
+ /// written when set, so older sessions and newer ones read the same.
+ /// Kept while `fill` is off, so turning the fill back on restores it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ fill_color: Option,
/// Border color
color: Color,
/// Border thickness in pixels
@@ -268,7 +280,13 @@ pub enum Shape {
points: Vec<(i32, i32)>,
/// Whether to fill the polygon.
fill: bool,
- /// Border/fill color.
+ /// Fill paint while `fill` is on. `None` fills with the border color,
+ /// as every fill did before fills had their own color; it is only
+ /// written when set, so older sessions and newer ones read the same.
+ /// Kept while `fill` is off, so turning the fill back on restores it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ fill_color: Option,
+ /// Border color, and the fill's unless `fill_color` is set.
color: Color,
/// Border thickness in pixels.
thick: f64,
diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs
index 1a767a4ed..1419c6639 100644
--- a/src/input/hit_test/tests.rs
+++ b/src/input/hit_test/tests.rs
@@ -34,6 +34,7 @@ fn compute_hit_bounds_inflates_bounds_for_tolerance() {
w: 30,
h: 40,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -108,6 +109,7 @@ fn rect_hit_handles_degenerate_dimensions() {
w: 0,
h: 20,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -129,6 +131,7 @@ fn ellipse_hit_handles_zero_radius() {
rx: 0,
ry: 0,
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -148,6 +151,7 @@ fn polygon_hit_tests_closed_outline_only() {
kind: PolygonKind::Triangle,
points: vec![(10, 10), (40, 10), (25, 40)],
fill: true,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -172,6 +176,7 @@ fn point_targeting_hits_filled_rect_and_ellipse_interiors() {
w: 40,
h: 30,
fill: true,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -186,6 +191,7 @@ fn point_targeting_hits_filled_rect_and_ellipse_interiors() {
rx: 20,
ry: 12,
fill: true,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -207,6 +213,7 @@ fn point_targeting_hits_filled_polygon_interior() {
kind: PolygonKind::Triangle,
points: vec![(10, 10), (40, 10), (25, 40)],
fill: true,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
@@ -228,6 +235,7 @@ fn invalid_polygon_hit_test_is_false() {
kind: PolygonKind::Freeform,
points: vec![(10, 10), (10, 10), (40, 10)],
fill: false,
+ fill_color: None,
color: BLACK,
thick: 2.0,
},
diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs
index 4b5133f4a..78ef65aeb 100644
--- a/src/input/state/actions/key_press/panels.rs
+++ b/src/input/state/actions/key_press/panels.rs
@@ -413,6 +413,9 @@ impl InputState {
measurer: &crate::draw::TextMeasurer,
key: Key,
) -> bool {
+ // A key pressed mid-drag ends the drag first, so what it changes is
+ // its own undo entry after the drag's rather than under it.
+ self.finish_properties_slider_drag_with(measurer);
let adjust_step = if self.modifiers.shift {
PROPERTIES_PANEL_COARSE_STEP
} else {
@@ -420,7 +423,9 @@ impl InputState {
};
match key {
Key::Escape => {
- self.close_properties_panel();
+ if !self.cancel_properties_preset_save() {
+ self.close_properties_panel();
+ }
true
}
Key::Up => self.focus_previous_properties_entry(),
diff --git a/src/input/state/core/color_picker_popup/mod.rs b/src/input/state/core/color_picker_popup/mod.rs
index 0ed626b98..a7775bfa0 100644
--- a/src/input/state/core/color_picker_popup/mod.rs
+++ b/src/input/state/core/color_picker_popup/mod.rs
@@ -100,6 +100,9 @@ pub enum ColorPickerTarget {
/// previewed live: OK recolors the selection in one undoable edit and
/// Cancel leaves it untouched.
Selection,
+ /// The selected closed shapes' fill, opened from the properties panel's
+ /// fill row. Like `Selection`, OK fills them in one undoable edit.
+ SelectionFill,
}
impl ColorPickerTarget {
@@ -109,7 +112,8 @@ impl ColorPickerTarget {
ColorPickerTarget::QuickColor(index) => Some(index),
ColorPickerTarget::Tool
| ColorPickerTarget::BoardPaper
- | ColorPickerTarget::Selection => None,
+ | ColorPickerTarget::Selection
+ | ColorPickerTarget::SelectionFill => None,
}
}
diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs
index 16b304b64..2c42fddc0 100644
--- a/src/input/state/core/color_picker_popup/state.rs
+++ b/src/input/state/core/color_picker_popup/state.rs
@@ -12,6 +12,7 @@ use super::{
PickerDrag, color_to_hex, hsv_to_rgb, parse_hex_color, rgb_to_hsv,
};
use crate::input::state::core::modal::ModalSurface;
+use crate::input::state::core::properties::RecolorOpacity;
/// Paper has no alpha, so every color the paper target takes is opaque.
fn opaque(color: Color) -> Color {
@@ -108,6 +109,21 @@ impl InputState {
true
}
+ /// Opens the popup on the selection's fill, from the properties panel's
+ /// fill row. Returns false when nothing selected can be filled.
+ pub(crate) fn open_color_picker_popup_for_selection_fill_with_measurer(
+ &mut self,
+ measurer: &TextMeasurer,
+ ) -> bool {
+ let Some(color) = self.selection_fill_paint_source() else {
+ return false;
+ };
+ self.discard_open_color_picker_recolor();
+ self.clear_properties_panel_pointer_state();
+ self.open_color_picker_popup_for(measurer, ColorPickerTarget::SelectionFill, color);
+ true
+ }
+
/// Whether the open popup edits the paper sheet's color draft.
pub fn color_picker_popup_edits_board_paper(&self) -> bool {
self.color_picker_popup.target() == Some(ColorPickerTarget::BoardPaper)
@@ -179,7 +195,10 @@ impl InputState {
ModalSurface::ColorPicker,
ModalSurface::BoardPicker,
);
- } else if target == ColorPickerTarget::Selection {
+ } else if matches!(
+ target,
+ ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill
+ ) {
// Opened from the properties panel, which shows the result.
self.close_modals_for_open_keeping(
ModalSurface::ColorPicker,
@@ -212,6 +231,7 @@ impl InputState {
}
Some(ColorPickerTarget::BoardPaper) => Cow::Borrowed("Paper Color"),
Some(ColorPickerTarget::Selection) => Cow::Borrowed("Selection Color"),
+ Some(ColorPickerTarget::SelectionFill) => Cow::Borrowed("Fill Color"),
Some(ColorPickerTarget::Tool) | None => Cow::Borrowed("Select Color"),
}
}
@@ -266,7 +286,7 @@ impl InputState {
// Shapes change on OK only, as one undo entry; a live preview
// would record one per drag step.
ColorPickerPopupState::Open {
- target: ColorPickerTarget::Selection,
+ target: ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill,
..
}
| ColorPickerPopupState::Hidden => {}
@@ -343,10 +363,18 @@ impl InputState {
// was only the first shape's, so a selection whose shapes disagree in
// hue or opacity still takes an accepted color that matches it.
if let Some((_, ColorPickerTarget::Selection, _, color)) = applied_color
- && self.selection_recolor_changes(color)
+ && self.selection_recolor_changes(color, RecolorOpacity::Exact)
+ {
+ self.finish_active_arrow_bend();
+ if self.recolor_selection_with(measurer, color, RecolorOpacity::Exact) {
+ self.note_recent_color(color);
+ }
+ }
+ if let Some((_, ColorPickerTarget::SelectionFill, _, color)) = applied_color
+ && self.selection_fill_paint_changes(Some(color), RecolorOpacity::Exact)
{
self.finish_active_arrow_bend();
- if self.apply_selection_color_value_with(measurer, color) {
+ if self.apply_selection_fill_paint_with(measurer, Some(color), RecolorOpacity::Exact) {
self.note_recent_color(color);
}
}
@@ -382,7 +410,7 @@ impl InputState {
// whether the board changes, so nothing is dirty yet.
ColorPickerTarget::BoardPaper => {}
// Applied above, whether or not the color moved.
- ColorPickerTarget::Selection => {}
+ ColorPickerTarget::Selection | ColorPickerTarget::SelectionFill => {}
}
}
self.color_picker_popup.hide();
diff --git a/src/input/state/core/editing/tests.rs b/src/input/state/core/editing/tests.rs
index 293f7b413..10f54291f 100644
--- a/src/input/state/core/editing/tests.rs
+++ b/src/input/state/core/editing/tests.rs
@@ -7,6 +7,7 @@ fn rectangle(x: i32) -> Shape {
w: 20,
h: 20,
fill: false,
+ fill_color: None,
color: crate::draw::WHITE,
thick: 2.0,
}
diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs
index 3d5222170..83adca458 100644
--- a/src/input/state/core/font_cycle.rs
+++ b/src/input/state/core/font_cycle.rs
@@ -242,6 +242,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0),
thick: 2.0,
});
diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs
index 4fee603c2..ff3f96cf5 100644
--- a/src/input/state/core/history.rs
+++ b/src/input/state/core/history.rs
@@ -28,6 +28,9 @@ impl InputState {
measurer: &TextMeasurer,
step: impl FnOnce(&mut Frame) -> Option,
) -> bool {
+ // A live slider drag holds snapshots from before it began; it has to
+ // land as its own entry before history moves past it.
+ self.finish_properties_slider_drag_with(measurer);
let selection_chrome = self.selection_chrome_bounds_with(measurer);
let Some(action) = step(self.boards.active_frame_mut()) else {
return false;
@@ -132,6 +135,7 @@ mod tests {
w: 10,
h: 12,
fill: false,
+ fill_color: None,
color: Color {
r: 0.2,
g: 0.4,
diff --git a/src/input/state/core/index/grid/tests.rs b/src/input/state/core/index/grid/tests.rs
index de798ca71..82fd93b89 100644
--- a/src/input/state/core/index/grid/tests.rs
+++ b/src/input/state/core/index/grid/tests.rs
@@ -13,6 +13,7 @@ fn filled_rect(x: i32, y: i32, width: i32, height: i32) -> Shape {
w: width,
h: height,
fill: true,
+ fill_color: None,
color: Color {
r: 0.0,
g: 0.0,
diff --git a/src/input/state/core/index/measurement_tests.rs b/src/input/state/core/index/measurement_tests.rs
index 315428d20..36b1ac7ac 100644
--- a/src/input/state/core/index/measurement_tests.rs
+++ b/src/input/state/core/index/measurement_tests.rs
@@ -175,6 +175,7 @@ fn explicit_point_targeting_keeps_fill_interiors_out_of_stroke_erasing() {
w: 100,
h: 100,
fill: true,
+ fill_color: None,
color: RED,
thick: 2.0,
});
diff --git a/src/input/state/core/index/owner.rs b/src/input/state/core/index/owner.rs
index 51c71233b..493554e51 100644
--- a/src/input/state/core/index/owner.rs
+++ b/src/input/state/core/index/owner.rs
@@ -404,6 +404,7 @@ mod canvas_index_owner_tests {
color: Color::new(0.0, 0.0, 0.0, 1.0),
thick: 2.0,
fill: false,
+ fill_color: None,
});
}
frame
diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs
index 1d8a8fbae..95127b5e4 100644
--- a/src/input/state/core/menus/commands.rs
+++ b/src/input/state/core/menus/commands.rs
@@ -113,6 +113,14 @@ impl InputState {
self.move_selection_to_front_with(resources.measurer);
self.close_context_menu();
}
+ MenuCommand::MoveForward => {
+ self.move_selection_forward_with(resources.measurer);
+ self.close_context_menu();
+ }
+ MenuCommand::MoveBackward => {
+ self.move_selection_backward_with(resources.measurer);
+ self.close_context_menu();
+ }
MenuCommand::MoveToBack => {
self.move_selection_to_back_with(resources.measurer);
self.close_context_menu();
diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs
index 8d85819a5..bc862c12a 100644
--- a/src/input/state/core/menus/entries/shape.rs
+++ b/src/input/state/core/menus/entries/shape.rs
@@ -52,19 +52,34 @@ impl InputState {
false,
Some(MenuCommand::Duplicate),
));
+ // Dimmed when the selection is already as far that way as it goes.
+ let can_raise = self.selection_can_step(true);
+ let can_lower = self.selection_can_step(false);
entries.push(
ContextMenuEntry::new(
"Move to Front",
self.shortcut_for_action(Action::MoveSelectionToFront),
- false,
+ !can_raise,
Some(MenuCommand::MoveToFront),
)
.with_separator(),
);
+ entries.push(ContextMenuEntry::new(
+ "Move Forward",
+ None::,
+ !can_raise,
+ Some(MenuCommand::MoveForward),
+ ));
+ entries.push(ContextMenuEntry::new(
+ "Move Backward",
+ None::,
+ !can_lower,
+ Some(MenuCommand::MoveBackward),
+ ));
entries.push(ContextMenuEntry::new(
"Move to Back",
self.shortcut_for_action(Action::MoveSelectionToBack),
- false,
+ !can_lower,
Some(MenuCommand::MoveToBack),
));
entries.push(ContextMenuEntry::new(
diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs
index 7e5a76f11..6e7bd05d1 100644
--- a/src/input/state/core/menus/types.rs
+++ b/src/input/state/core/menus/types.rs
@@ -64,6 +64,8 @@ pub enum MenuCommand {
Duplicate,
SelectHoveredShape,
MoveToFront,
+ MoveForward,
+ MoveBackward,
MoveToBack,
Lock,
Unlock,
diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs
index ab5f325fc..3f60ceed1 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -126,9 +126,9 @@ pub use menus::{
pub use menus::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY};
pub(crate) use properties::metrics as properties_panel_metrics;
pub use properties::{
- PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock,
- PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry,
- SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel,
+ LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout,
+ PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry,
+ SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel,
};
pub use radial_menu::{
COMPASS_SLICES as RADIAL_COMPASS_SLICES, CompassDir, RADIAL_PAINT_DELAY, RadialMenuLayout,
diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs
index a717a844e..526cd9fc7 100644
--- a/src/input/state/core/modal.rs
+++ b/src/input/state/core/modal.rs
@@ -254,6 +254,14 @@ impl InputState {
.any(|surface| surface.blocks_canvas_key_repeat() && self.modal_is_open(surface))
}
+ /// Whether a canvas press would only close an open popup. The properties
+ /// panel and the context menu both spend the first press outside them on
+ /// dismissing themselves, so while one is open the canvas draws nothing
+ /// and a tool's cursor preview would promise a stroke that never comes.
+ pub(crate) fn canvas_press_dismisses_popup(&self) -> bool {
+ self.is_properties_panel_open() || self.is_context_menu_open()
+ }
+
/// Whether an open surface claims the wheel, so an axis frame must not
/// fall through to the canvas tool behind it.
///
@@ -384,6 +392,7 @@ mod wheel_tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0),
thick: 2.0,
});
@@ -393,6 +402,33 @@ mod wheel_tests {
assert!(state.is_properties_panel_open());
assert!(!state.modal_owns_wheel());
}
+
+ #[test]
+ fn a_popup_a_canvas_click_would_only_dismiss_hides_the_tool_preview() {
+ let measurer = crate::draw::TextMeasurer::default();
+ let mut state = make_test_input_state();
+ assert!(!state.canvas_press_dismisses_popup());
+ let id = state
+ .boards
+ .active_frame_mut()
+ .add_shape(crate::draw::Shape::Rect {
+ x: 0,
+ y: 0,
+ w: 10,
+ h: 10,
+ fill: false,
+ fill_color: None,
+ color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0),
+ thick: 2.0,
+ });
+ state.set_selection(vec![id]);
+
+ assert!(state.show_properties_panel_with(&measurer));
+ assert!(state.canvas_press_dismisses_popup());
+
+ state.close_properties_panel();
+ assert!(!state.canvas_press_dismisses_popup());
+ }
}
#[cfg(test)]
diff --git a/src/input/state/core/presets.rs b/src/input/state/core/presets.rs
index 4e592419d..f26972bfa 100644
--- a/src/input/state/core/presets.rs
+++ b/src/input/state/core/presets.rs
@@ -162,6 +162,7 @@ mod tests {
eraser_mode: None,
marker_opacity: None,
fill_enabled: None,
+ fill_color: None,
font_size: None,
text_background_enabled: None,
arrow_length: None,
diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs
index 8e2818677..9c6fa9c4d 100644
--- a/src/input/state/core/properties/apply.rs
+++ b/src/input/state/core/properties/apply.rs
@@ -1,7 +1,8 @@
use super::super::base::InputState;
+use super::RecolorOpacity;
use super::types::{
- PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry, SelectionPropertyKind,
- SelectionPropertyValue,
+ PanelAction, PropertiesPanelHit, PropertiesPanelLock, SelectionPropertyEntry,
+ SelectionPropertyKind, SelectionPropertyValue,
};
use crate::draw::{ArrowStyle, Color, Shape, TextMeasurer};
@@ -14,15 +15,24 @@ impl InputState {
measurer: &TextMeasurer,
hit: PropertiesPanelHit,
) -> bool {
+ // Locking, ordering, presets: none may record history under a drag.
+ self.finish_properties_slider_drag_with(measurer);
if hit == PropertiesPanelHit::Lock {
return self.toggle_properties_panel_lock_with(measurer);
}
+ if let PropertiesPanelHit::Action(action) = hit {
+ return self.run_properties_panel_action_with(measurer, action);
+ }
let Some(entry) = hit.row().and_then(|row| self.enabled_properties_entry(row)) else {
return false;
};
let changed = match hit {
- PropertiesPanelHit::Title | PropertiesPanelHit::Lock => false,
+ // A slider acts on the press and the drag, not on the click.
+ PropertiesPanelHit::Title
+ | PropertiesPanelHit::Lock
+ | PropertiesPanelHit::Action(_)
+ | PropertiesPanelHit::Slider(_) => false,
PropertiesPanelHit::Swatch { index, .. } => {
let Some(color) = self
.properties
@@ -33,10 +43,21 @@ impl InputState {
else {
return false;
};
- self.set_selection_color_from_panel(measurer, color)
+ match entry.kind {
+ SelectionPropertyKind::Fill => {
+ self.set_selection_fill_from_panel(measurer, Some(color))
+ }
+ _ => self.set_selection_color_from_panel(measurer, color),
+ }
}
+ PropertiesPanelHit::NoFill(_) => self.set_selection_fill_from_panel(measurer, None),
PropertiesPanelHit::MoreColors(_) => {
- return self.open_color_picker_popup_for_selection_with_measurer(measurer);
+ return match entry.kind {
+ SelectionPropertyKind::Fill => {
+ self.open_color_picker_popup_for_selection_fill_with_measurer(measurer)
+ }
+ _ => self.open_color_picker_popup_for_selection_with_measurer(measurer),
+ };
}
PropertiesPanelHit::StepDown(_) => {
self.dispatch_selection_property(measurer, entry.kind, -1)
@@ -113,13 +134,25 @@ impl InputState {
// Picking the color the selection already has is a no-op, not a
// "No changes applied" toast. A swatch with the same hue still
// changes a shape of another opacity.
- if !self.selection_recolor_changes(color) {
+ if !self.selection_recolor_changes(color, RecolorOpacity::Swatch) {
return false;
}
self.finish_active_arrow_bend();
self.apply_selection_color_value_with(measurer, color)
}
+ fn set_selection_fill_from_panel(
+ &mut self,
+ measurer: &TextMeasurer,
+ paint: Option,
+ ) -> bool {
+ if !self.selection_fill_paint_changes(paint, RecolorOpacity::Swatch) {
+ return false;
+ }
+ self.finish_active_arrow_bend();
+ self.apply_selection_fill_paint_with(measurer, paint, RecolorOpacity::Swatch)
+ }
+
fn set_selection_arrow_style_from_panel(
&mut self,
measurer: &TextMeasurer,
@@ -131,6 +164,70 @@ impl InputState {
self.apply_selection_arrow_style_value(measurer, style)
}
+ /// Runs an actions-area button through the same selection edits as the
+ /// context menu and the keyboard. A delete empties the selection, and the
+ /// refresh then closes the panel with nothing left to show.
+ fn run_properties_panel_action_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ action: PanelAction,
+ ) -> bool {
+ let enabled = self
+ .properties
+ .panel
+ .as_ref()
+ .is_some_and(|panel| panel.action_enabled(action));
+ if !enabled {
+ return false;
+ }
+
+ let changed = match action {
+ PanelAction::ToBack => self.move_selection_to_back_with(measurer),
+ PanelAction::Backward => self.move_selection_backward_with(measurer),
+ PanelAction::Forward => self.move_selection_forward_with(measurer),
+ PanelAction::ToFront => self.move_selection_to_front_with(measurer),
+ PanelAction::Duplicate => {
+ // Duplicating selects the copies, and any selection change
+ // closes the panel; reopen it on the copies it was asked for.
+ let duplicated = self.duplicate_selection_with(measurer);
+ if duplicated && !self.is_properties_panel_open() {
+ let _ = self.show_properties_panel_with(measurer);
+ }
+ duplicated
+ }
+ PanelAction::Delete => self.delete_selection_with(measurer),
+ PanelAction::SavePreset => {
+ if let Some(panel) = self.properties.panel.as_mut() {
+ panel.preset_save_mode = !panel.preset_save_mode;
+ }
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ return true;
+ }
+ PanelAction::Preset(slot) => {
+ let saving = self
+ .properties
+ .panel
+ .as_ref()
+ .is_some_and(|panel| panel.preset_save_mode);
+ if saving {
+ if let Some(panel) = self.properties.panel.as_mut() {
+ panel.preset_save_mode = false;
+ }
+ self.selection_preset_source()
+ .is_some_and(|preset| self.store_preset(slot, preset))
+ } else {
+ self.apply_preset_to_selection_with(measurer, slot)
+ }
+ }
+ };
+
+ if changed && self.is_properties_panel_open() {
+ self.refresh_properties_panel_with(measurer);
+ }
+ changed
+ }
+
/// Locks every selected shape, or unlocks them all once every one is
/// locked. Locked shapes refuse edits, so this is also how the panel's
/// disabled rows come back.
@@ -276,6 +373,9 @@ impl InputState {
SelectionPropertyKind::Thickness => {
self.apply_selection_thickness(measurer, direction_or_default(direction))
}
+ SelectionPropertyKind::Opacity => {
+ self.apply_selection_opacity(measurer, direction_or_default(direction))
+ }
SelectionPropertyKind::Fill => self.apply_selection_fill(measurer, direction),
SelectionPropertyKind::FontSize => {
self.apply_selection_font_size(measurer, direction_or_default(direction))
diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs
index 93d525f48..bc169e3d3 100644
--- a/src/input/state/core/properties/apply_selection/actions/color.rs
+++ b/src/input/state/core/properties/apply_selection/actions/color.rs
@@ -1,15 +1,47 @@
use crate::draw::TextMeasurer;
use crate::draw::{Color, Shape};
use crate::input::state::core::base::InputState;
+use crate::input::state::core::properties::apply_selection::constants::{
+ MAX_MARKER_OPACITY, MIN_OPACITY, OPACITY_STEP,
+};
use crate::input::state::core::properties::utils::{cycle_index, palette_position, palette_step};
use crate::input::state::{Toast, ToastPriority};
+/// How a recolor treats each shape's opacity.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RecolorOpacity {
+ /// A swatch or color key picks the hue. Opacity has its own control, so
+ /// each shape keeps its own, unless the swatch itself is translucent:
+ /// then its opacity is part of the choice.
+ Swatch,
+ /// The color picker's OK sets exactly the color shown, opacity included.
+ Exact,
+}
+
/// The color `shape` has now and the one a recolor to `target` gives it, or
-/// `None` for a shape without a color. A marker keeps its own opacity, which
-/// is what makes it a highlighter; every other shape takes `target` whole,
-/// opacity included.
-fn recolored(shape: &Shape, target: Color) -> Option<(Color, Color)> {
- let current = match shape {
+/// `None` for a shape without a color. A marker keeps its own opacity either
+/// way, which is what makes it a highlighter.
+pub(super) fn recolored(
+ shape: &Shape,
+ target: Color,
+ opacity: RecolorOpacity,
+) -> Option<(Color, Color)> {
+ let current = *shape_color_ref(shape)?;
+ let keep_opacity = matches!(shape, Shape::MarkerStroke { .. })
+ || (opacity == RecolorOpacity::Swatch && target.a >= 1.0);
+ let next = if keep_opacity {
+ Color {
+ a: current.a,
+ ..target
+ }
+ } else {
+ target
+ };
+ Some((current, next))
+}
+
+fn shape_color_ref(shape: &Shape) -> Option<&Color> {
+ match shape {
Shape::Freehand { color, .. }
| Shape::FreehandPressure { color, .. }
| Shape::Line { color, .. }
@@ -19,21 +51,13 @@ fn recolored(shape: &Shape, target: Color) -> Option<(Color, Color)> {
| Shape::Arrow { color, .. }
| Shape::MarkerStroke { color, .. }
| Shape::Text { color, .. }
- | Shape::StepMarker { color, .. } => *color,
- Shape::StickyNote { background, .. } => *background,
- _ => return None,
- };
- let next = match shape {
- Shape::MarkerStroke { .. } => Color {
- a: current.a,
- ..target
- },
- _ => target,
- };
- Some((current, next))
+ | Shape::StepMarker { color, .. } => Some(color),
+ Shape::StickyNote { background, .. } => Some(background),
+ _ => None,
+ }
}
-fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> {
+pub(super) fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> {
match shape {
Shape::Freehand { color, .. }
| Shape::FreehandPressure { color, .. }
@@ -50,17 +74,74 @@ fn shape_color_mut(shape: &mut Shape) -> Option<&mut Color> {
}
}
+/// The opacity range a shape may take: a marker stays translucent, as the
+/// marker tool keeps it.
+fn opacity_range(shape: &Shape) -> (f64, f64) {
+ match shape {
+ Shape::MarkerStroke { .. } => (MIN_OPACITY, MAX_MARKER_OPACITY),
+ _ => (MIN_OPACITY, 1.0),
+ }
+}
+
+pub(super) fn has_color(shape: &Shape) -> bool {
+ shape_color_ref(shape).is_some()
+}
+
+/// Sets a shape's opacity to `value`, clamped to its range.
+pub(super) fn set_opacity_to(shape: &mut Shape, value: f64) -> Option {
+ set_opacity(shape, |_| value)
+}
+
+/// Sets a shape's opacity, clamped to its range: whether it changed, or
+/// `None` for a shape without a color.
+fn set_opacity(shape: &mut Shape, opacity: impl FnOnce(f64) -> f64) -> Option {
+ let (min, max) = opacity_range(shape);
+ let color = shape_color_mut(shape)?;
+ let next = opacity(color.a).clamp(min, max);
+ let mut changed = (next - color.a).abs() > f64::EPSILON;
+ color.a = next;
+ // One opacity per shape: a fill with its own color follows the border.
+ if let Shape::Rect {
+ fill_color: Some(fill),
+ ..
+ }
+ | Shape::Ellipse {
+ fill_color: Some(fill),
+ ..
+ }
+ | Shape::Polygon {
+ fill_color: Some(fill),
+ ..
+ } = shape
+ {
+ changed |= (next - fill.a).abs() > f64::EPSILON;
+ fill.a = next;
+ }
+ Some(changed)
+}
+
impl InputState {
+ /// Recolors the selection from a swatch, a color key, or the eyedropper:
+ /// see [`RecolorOpacity::Swatch`].
pub(crate) fn apply_selection_color_value_with(
&mut self,
measurer: &TextMeasurer,
target: Color,
+ ) -> bool {
+ self.recolor_selection_with(measurer, target, RecolorOpacity::Swatch)
+ }
+
+ pub(crate) fn recolor_selection_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ target: Color,
+ opacity: RecolorOpacity,
) -> bool {
let result = self.apply_selection_change_with(
measurer,
- |shape| recolored(shape, target).is_some(),
+ |shape| recolored(shape, target, opacity).is_some(),
|shape| {
- let Some((current, next)) = recolored(shape, target) else {
+ let Some((current, next)) = recolored(shape, target, opacity) else {
return false;
};
if current == next {
@@ -78,18 +159,40 @@ impl InputState {
}
/// Whether recoloring the selection to `target` would change any shape it
- /// may edit, opacity included. Surfaces that set a color directly ask
- /// this first, so picking the color a selection already has is quiet.
- pub(crate) fn selection_recolor_changes(&self, target: Color) -> bool {
+ /// may edit. Surfaces that set a color directly ask this first, so picking
+ /// the color a selection already has is quiet.
+ pub(crate) fn selection_recolor_changes(&self, target: Color, opacity: RecolorOpacity) -> bool {
let frame = self.boards.active_frame();
self.selected_shape_ids()
.iter()
.filter_map(|id| frame.shape(*id))
.filter(|drawn| !drawn.locked)
- .filter_map(|drawn| recolored(&drawn.shape, target))
+ .filter_map(|drawn| recolored(&drawn.shape, target, opacity))
.any(|(current, next)| current != next)
}
+ /// Steps every editable selected shape's opacity by `direction` steps of
+ /// 5%, on the 5% grid, within its range.
+ pub(in crate::input::state::core::properties) fn apply_selection_opacity(
+ &mut self,
+ measurer: &TextMeasurer,
+ direction: i32,
+ ) -> bool {
+ let steps = f64::from(direction);
+ let result = self.apply_selection_change_with(
+ measurer,
+ |shape| shape_color_ref(shape).is_some(),
+ |shape| {
+ set_opacity(shape, |alpha| {
+ ((alpha / OPACITY_STEP).round() + steps) * OPACITY_STEP
+ })
+ .unwrap_or(false)
+ },
+ );
+
+ self.report_selection_apply_result(result, "opacity")
+ }
+
/// Steps the selection's color through the quick-color palette, the same
/// swatches the toolbar and the properties panel offer.
pub(in crate::input::state::core::properties) fn apply_selection_color(
@@ -121,7 +224,7 @@ impl InputState {
// nothing, and stopping there would pin every later step to it.
let mut next = first;
for _ in 0..palette.len() {
- if self.selection_recolor_changes(palette[next]) {
+ if self.selection_recolor_changes(palette[next], RecolorOpacity::Swatch) {
break;
}
next = cycle_index(next, palette.len(), offset);
@@ -195,6 +298,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color,
thick: 2.0,
})
diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs
index f8ba5f22f..8058b062b 100644
--- a/src/input/state/core/properties/apply_selection/actions/fill.rs
+++ b/src/input/state/core/properties/apply_selection/actions/fill.rs
@@ -1,5 +1,6 @@
-use crate::draw::Shape;
+use super::color::RecolorOpacity;
use crate::draw::TextMeasurer;
+use crate::draw::{Color, Shape};
use crate::input::state::core::base::InputState;
use crate::input::state::{Toast, ToastPriority};
@@ -52,6 +53,145 @@ impl InputState {
self.report_selection_apply_result(result, "fill")
}
+
+ /// Fills every editable selected closed shape with `paint`, or turns the
+ /// fill off for `None`. An opaque swatch fills at the shape's own
+ /// opacity, the way the border's swatches keep it; a translucent one
+ /// brings its own. Turning the fill off keeps the color, so turning it
+ /// back on restores it.
+ pub(crate) fn apply_selection_fill_paint_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ paint: Option,
+ opacity: RecolorOpacity,
+ ) -> bool {
+ let result = self.apply_selection_change_with(
+ measurer,
+ |shape| filled(shape, paint, opacity).is_some(),
+ |shape| {
+ let Some(next) = filled(shape, paint, opacity) else {
+ return false;
+ };
+ let Some((fill, fill_color)) = fill_fields(shape) else {
+ return false;
+ };
+ if (*fill, *fill_color) == next {
+ return false;
+ }
+ (*fill, *fill_color) = next;
+ true
+ },
+ );
+
+ self.report_selection_apply_result(result, "fill")
+ }
+
+ /// The fill of the first editable selected closed shape, for the color
+ /// picker to open on; `None` when no closed shape can be filled.
+ pub(crate) fn selection_fill_paint_source(&self) -> Option {
+ let frame = self.boards.active_frame();
+ self.selected_shape_ids()
+ .iter()
+ .filter_map(|id| frame.shape(*id))
+ .filter(|drawn| !drawn.locked)
+ .find_map(|drawn| fill_paint(&drawn.shape))
+ }
+
+ /// Whether filling the selection with `paint` would change any shape it
+ /// may edit, so picking the fill a selection already has is quiet.
+ pub(crate) fn selection_fill_paint_changes(
+ &self,
+ paint: Option,
+ opacity: RecolorOpacity,
+ ) -> bool {
+ let frame = self.boards.active_frame();
+ self.selected_shape_ids()
+ .iter()
+ .filter_map(|id| frame.shape(*id))
+ .filter(|drawn| !drawn.locked)
+ .filter_map(|drawn| filled(&drawn.shape, paint, opacity).zip(fill_state(&drawn.shape)))
+ .any(|(next, current)| next != current)
+ }
+}
+
+/// The fill a closed shape shows, or would show once filled: its own fill
+/// color, or its border's.
+fn fill_paint(shape: &Shape) -> Option {
+ match shape {
+ Shape::Rect {
+ fill_color, color, ..
+ }
+ | Shape::Ellipse {
+ fill_color, color, ..
+ }
+ | Shape::Polygon {
+ fill_color, color, ..
+ } => Some(fill_color.unwrap_or(*color)),
+ _ => None,
+ }
+}
+
+/// A closed shape's `(fill, fill_color)`, or `None` for a shape with no fill.
+fn fill_state(shape: &Shape) -> Option<(bool, Option)> {
+ match shape {
+ Shape::Rect {
+ fill, fill_color, ..
+ }
+ | Shape::Ellipse {
+ fill, fill_color, ..
+ }
+ | Shape::Polygon {
+ fill, fill_color, ..
+ } => Some((*fill, *fill_color)),
+ _ => None,
+ }
+}
+
+/// The `(fill, fill_color)` a fill with `paint` leaves on `shape`, or `None`
+/// for a shape that has no fill.
+fn filled(
+ shape: &Shape,
+ paint: Option,
+ opacity: RecolorOpacity,
+) -> Option<(bool, Option)> {
+ let (current_fill_color, border) = match shape {
+ Shape::Rect {
+ fill_color, color, ..
+ }
+ | Shape::Ellipse {
+ fill_color, color, ..
+ }
+ | Shape::Polygon {
+ fill_color, color, ..
+ } => (*fill_color, *color),
+ _ => return None,
+ };
+ Some(match paint {
+ None => (false, current_fill_color),
+ Some(swatch) => {
+ let alpha = if opacity == RecolorOpacity::Swatch && swatch.a >= 1.0 {
+ border.a
+ } else {
+ swatch.a
+ };
+ (true, Some(Color { a: alpha, ..swatch }))
+ }
+ })
+}
+
+fn fill_fields(shape: &mut Shape) -> Option<(&mut bool, &mut Option)> {
+ match shape {
+ Shape::Rect {
+ fill, fill_color, ..
+ }
+ | Shape::Ellipse {
+ fill, fill_color, ..
+ }
+ | Shape::Polygon {
+ fill, fill_color, ..
+ } => Some((fill, fill_color)),
+ _ => None,
+ }
}
#[cfg(test)]
@@ -78,6 +218,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: state.style.current_color,
thick: 2.0,
});
@@ -87,6 +228,7 @@ mod tests {
rx: 6,
ry: 7,
fill: true,
+ fill_color: None,
color: state.style.current_color,
thick: 2.0,
});
diff --git a/src/input/state/core/properties/apply_selection/actions/mod.rs b/src/input/state/core/properties/apply_selection/actions/mod.rs
index 1e80d1cf6..69227e051 100644
--- a/src/input/state/core/properties/apply_selection/actions/mod.rs
+++ b/src/input/state/core/properties/apply_selection/actions/mod.rs
@@ -1,6 +1,44 @@
mod arrow;
mod color;
mod fill;
+mod preset;
mod spotlight;
mod stroke;
mod text;
+
+pub(crate) use color::RecolorOpacity;
+
+use crate::draw::Shape;
+use crate::input::state::core::base::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
+use crate::input::state::core::properties::summary::shape_thickness;
+use crate::input::state::core::properties::types::SelectionPropertyKind;
+
+/// Whether a slider for `kind` has anything to set on `shape`.
+pub(in crate::input::state::core::properties) fn level_applies(
+ kind: SelectionPropertyKind,
+ shape: &Shape,
+) -> bool {
+ match kind {
+ SelectionPropertyKind::Thickness => shape_thickness(shape).is_some(),
+ SelectionPropertyKind::Opacity => color::has_color(shape),
+ _ => false,
+ }
+}
+
+/// Sets `kind` to `value` on one shape, as a slider drag previews it:
+/// whether the shape changed.
+pub(in crate::input::state::core::properties) fn set_level(
+ kind: SelectionPropertyKind,
+ shape: &mut Shape,
+ value: f64,
+) -> bool {
+ match kind {
+ SelectionPropertyKind::Thickness => stroke::set_thickness(
+ shape,
+ value.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS),
+ )
+ .unwrap_or(false),
+ SelectionPropertyKind::Opacity => color::set_opacity_to(shape, value).unwrap_or(false),
+ _ => false,
+ }
+}
diff --git a/src/input/state/core/properties/apply_selection/actions/preset.rs b/src/input/state/core/properties/apply_selection/actions/preset.rs
new file mode 100644
index 000000000..f96286c82
--- /dev/null
+++ b/src/input/state/core/properties/apply_selection/actions/preset.rs
@@ -0,0 +1,233 @@
+//! Tool presets and selected shapes: a preset's style applied to what is
+//! selected, and a selected shape's style saved as a preset.
+
+use super::color::{RecolorOpacity, recolored, set_opacity_to, shape_color_mut};
+use super::stroke::set_thickness;
+use crate::config::{ColorSpec, ToolPresetConfig};
+use crate::draw::{Color, PolygonKind, Shape, TextMeasurer};
+use crate::input::Tool;
+use crate::input::state::core::base::{InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
+use crate::input::state::core::properties::apply_selection::constants::{
+ MAX_ARROW_ANGLE, MAX_ARROW_LENGTH, MAX_FONT_SIZE, MIN_ARROW_ANGLE, MIN_ARROW_LENGTH,
+ MIN_FONT_SIZE,
+};
+
+/// Applies the parts of `preset` that `shape` has: its color (a marker
+/// keeping its own translucency unless the preset sets one), its size as a
+/// stroke width, fill, font size, text background, and arrow head.
+fn apply_preset_style(shape: &mut Shape, preset: &ToolPresetConfig) -> bool {
+ let mut changed = false;
+
+ if let Some((current, next)) = recolored(shape, preset.preview_color(), RecolorOpacity::Exact)
+ && current != next
+ && let Some(color) = shape_color_mut(shape)
+ {
+ *color = next;
+ changed = true;
+ }
+ if !preset.tool.uses_eraser_size() {
+ let size = preset
+ .preview_size()
+ .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS);
+ changed |= set_thickness(shape, size).unwrap_or(false);
+ }
+ if let (Shape::MarkerStroke { .. }, Some(opacity)) = (&*shape, preset.marker_opacity) {
+ changed |= set_opacity_to(shape, opacity).unwrap_or(false);
+ }
+
+ match shape {
+ Shape::Rect {
+ fill, fill_color, ..
+ }
+ | Shape::Ellipse {
+ fill, fill_color, ..
+ }
+ | Shape::Polygon {
+ fill, fill_color, ..
+ } => {
+ // A preset with a fill setting carries the whole fill: its own
+ // color, or none, which fills with the border color the preset
+ // just set.
+ if let Some(enabled) = preset.fill_enabled {
+ changed |= replace(fill, enabled);
+ let preset_fill = preset.fill_color.as_ref().map(ColorSpec::to_color);
+ changed |= replace(fill_color, preset_fill);
+ }
+ }
+ Shape::Text {
+ size,
+ background_enabled,
+ ..
+ } => {
+ if let Some(font_size) = preset.font_size {
+ changed |= replace(size, font_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE));
+ }
+ if let Some(enabled) = preset.text_background_enabled {
+ changed |= replace(background_enabled, enabled);
+ }
+ }
+ Shape::Arrow {
+ arrow_length,
+ arrow_angle,
+ head_at_end,
+ ..
+ } => {
+ if let Some(length) = preset.arrow_length {
+ changed |= replace(
+ arrow_length,
+ length.clamp(MIN_ARROW_LENGTH, MAX_ARROW_LENGTH),
+ );
+ }
+ if let Some(angle) = preset.arrow_angle {
+ changed |= replace(arrow_angle, angle.clamp(MIN_ARROW_ANGLE, MAX_ARROW_ANGLE));
+ }
+ if let Some(at_end) = preset.arrow_head_at_end {
+ changed |= replace(head_at_end, at_end);
+ }
+ }
+ _ => {}
+ }
+ changed
+}
+
+fn replace(slot: &mut T, value: T) -> bool {
+ let changed = *slot != value;
+ *slot = value;
+ changed
+}
+
+/// The preset a tool would need to draw `shape` as it is, or `None` for a
+/// shape no tool draws with a color and a size.
+fn preset_from_shape(shape: &Shape) -> Option {
+ let mut preset = ToolPresetConfig {
+ name: None,
+ tool: Tool::Pen,
+ color: ColorSpec::from(Color::new(0.0, 0.0, 0.0, 1.0)),
+ size: 1.0,
+ tool_settings: None,
+ eraser_kind: None,
+ eraser_mode: None,
+ marker_opacity: None,
+ fill_enabled: None,
+ fill_color: None,
+ font_size: None,
+ text_background_enabled: None,
+ arrow_length: None,
+ arrow_angle: None,
+ arrow_head_at_end: None,
+ polygon_sides: None,
+ show_status_bar: None,
+ drag_tools: None,
+ };
+ let (tool, color, size) = match shape {
+ Shape::Freehand { color, thick, .. } => (Tool::Pen, *color, *thick),
+ Shape::Line { color, thick, .. } => (Tool::Line, *color, *thick),
+ Shape::Rect {
+ color,
+ thick,
+ fill,
+ fill_color,
+ ..
+ } => {
+ preset.fill_enabled = Some(*fill);
+ preset.fill_color = fill_color.map(ColorSpec::from);
+ (Tool::Rect, *color, *thick)
+ }
+ Shape::Ellipse {
+ color,
+ thick,
+ fill,
+ fill_color,
+ ..
+ } => {
+ preset.fill_enabled = Some(*fill);
+ preset.fill_color = fill_color.map(ColorSpec::from);
+ (Tool::Ellipse, *color, *thick)
+ }
+ Shape::Polygon {
+ kind,
+ color,
+ thick,
+ fill,
+ fill_color,
+ ..
+ } => {
+ preset.fill_enabled = Some(*fill);
+ preset.fill_color = fill_color.map(ColorSpec::from);
+ let tool = match kind {
+ PolygonKind::Triangle => Tool::Triangle,
+ PolygonKind::Parallelogram => Tool::Parallelogram,
+ PolygonKind::Rhombus => Tool::Rhombus,
+ PolygonKind::Regular { sides } => {
+ preset.polygon_sides = Some(*sides);
+ Tool::RegularPolygon
+ }
+ PolygonKind::Freeform => Tool::FreeformPolygon,
+ };
+ (tool, *color, *thick)
+ }
+ Shape::Arrow {
+ color,
+ thick,
+ arrow_length,
+ arrow_angle,
+ head_at_end,
+ ..
+ } => {
+ preset.arrow_length = Some(*arrow_length);
+ preset.arrow_angle = Some(*arrow_angle);
+ preset.arrow_head_at_end = Some(*head_at_end);
+ (Tool::Arrow, *color, *thick)
+ }
+ Shape::MarkerStroke { color, thick, .. } => {
+ preset.marker_opacity = Some(color.a);
+ (Tool::Marker, Color { a: 1.0, ..*color }, *thick)
+ }
+ _ => return None,
+ };
+ preset.tool = tool;
+ preset.color = ColorSpec::from(color);
+ preset.size = size;
+ Some(preset)
+}
+
+impl InputState {
+ /// Styles every editable selected shape after preset `slot`, as one undo
+ /// entry. The tool in use stays as it is: this restyles shapes, it does
+ /// not pick up the preset's tool.
+ pub(in crate::input::state::core::properties) fn apply_preset_to_selection_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ slot: usize,
+ ) -> bool {
+ let Some(preset) = self.preset_slots.preset(slot) else {
+ return false;
+ };
+
+ self.finish_active_arrow_bend();
+ let result = self.apply_selection_change_with(measurer, shape_supports_presets, |shape| {
+ apply_preset_style(shape, &preset)
+ });
+
+ self.report_selection_apply_result(result, "preset")
+ }
+
+ /// The preset for the first editable selected shape a tool can draw.
+ pub(in crate::input::state::core::properties) fn selection_preset_source(
+ &self,
+ ) -> Option {
+ let frame = self.boards.active_frame();
+ self.selected_shape_ids()
+ .iter()
+ .filter_map(|id| frame.shape(*id))
+ .filter(|drawn| !drawn.locked)
+ .find_map(|drawn| preset_from_shape(&drawn.shape))
+ }
+}
+
+fn shape_supports_presets(shape: &Shape) -> bool {
+ !matches!(
+ shape,
+ Shape::Image { .. } | Shape::EraserStroke { .. } | Shape::Spotlight { .. }
+ )
+}
diff --git a/src/input/state/core/properties/apply_selection/actions/stroke.rs b/src/input/state/core/properties/apply_selection/actions/stroke.rs
index 84360b907..634aa47a3 100644
--- a/src/input/state/core/properties/apply_selection/actions/stroke.rs
+++ b/src/input/state/core/properties/apply_selection/actions/stroke.rs
@@ -91,3 +91,25 @@ impl InputState {
self.report_selection_apply_result(result, "thickness")
}
}
+
+/// Sets a stroke's width to `target`: whether it changed, or `None` for a
+/// shape without one.
+pub(super) fn set_thickness(shape: &mut Shape, target: f64) -> Option {
+ match shape {
+ Shape::Freehand { thick, .. }
+ | Shape::Line { thick, .. }
+ | Shape::Rect { thick, .. }
+ | Shape::Ellipse { thick, .. }
+ | Shape::Polygon { thick, .. }
+ | Shape::Arrow { thick, .. }
+ | Shape::BlurRect {
+ strength: thick, ..
+ }
+ | Shape::MarkerStroke { thick, .. } => {
+ let changed = (*thick - target).abs() > f64::EPSILON;
+ *thick = target;
+ Some(changed)
+ }
+ _ => None,
+ }
+}
diff --git a/src/input/state/core/properties/apply_selection/actions/text.rs b/src/input/state/core/properties/apply_selection/actions/text.rs
index 7dd462729..e29c74b24 100644
--- a/src/input/state/core/properties/apply_selection/actions/text.rs
+++ b/src/input/state/core/properties/apply_selection/actions/text.rs
@@ -100,6 +100,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: state.style.current_color,
thick: 2.0,
});
diff --git a/src/input/state/core/properties/apply_selection/constants.rs b/src/input/state/core/properties/apply_selection/constants.rs
index 64046df6c..fdf198dbc 100644
--- a/src/input/state/core/properties/apply_selection/constants.rs
+++ b/src/input/state/core/properties/apply_selection/constants.rs
@@ -8,3 +8,8 @@ pub(super) const MIN_ARROW_LENGTH: f64 = 5.0;
pub(super) const MAX_ARROW_LENGTH: f64 = 50.0;
pub(super) const MIN_ARROW_ANGLE: f64 = 15.0;
pub(super) const MAX_ARROW_ANGLE: f64 = 60.0;
+/// Opacity steps in 5% increments from 5%; a marker stays at or below 90%,
+/// the marker tool's own ceiling.
+pub(in crate::input::state::core::properties) const OPACITY_STEP: f64 = 0.05;
+pub(in crate::input::state::core::properties) const MIN_OPACITY: f64 = 0.05;
+pub(in crate::input::state::core::properties) const MAX_MARKER_OPACITY: f64 = 0.9;
diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs
index 134200ebc..8e48904bf 100644
--- a/src/input/state/core/properties/apply_selection/helpers.rs
+++ b/src/input/state/core/properties/apply_selection/helpers.rs
@@ -65,6 +65,11 @@ impl InputState {
A: FnMut(&Shape) -> bool,
F: FnMut(&mut Shape) -> bool,
{
+ // Every selection edit comes through here. A slider drag still in
+ // progress holds snapshots from before it began, so it commits first:
+ // committing after this edit's entry would record its "before" on top
+ // of it, and undoing would walk back through a state never shown.
+ self.finish_properties_slider_drag_with(measurer);
let ids = self.selected_shape_ids().to_vec();
let (changed, locked, applicable, effects) =
crate::input::state::core::editing::CanvasEdit::apply_selection(
diff --git a/src/input/state/core/properties/apply_selection/helpers/tests.rs b/src/input/state/core/properties/apply_selection/helpers/tests.rs
index 0825bb2bc..f0457eec4 100644
--- a/src/input/state/core/properties/apply_selection/helpers/tests.rs
+++ b/src/input/state/core/properties/apply_selection/helpers/tests.rs
@@ -26,6 +26,7 @@ fn add_rect(
w: 30,
h: 40,
fill,
+ fill_color: None,
color,
thick: 2.0,
});
diff --git a/src/input/state/core/properties/apply_selection/mod.rs b/src/input/state/core/properties/apply_selection/mod.rs
index aff2b48f2..1c5380a66 100644
--- a/src/input/state/core/properties/apply_selection/mod.rs
+++ b/src/input/state/core/properties/apply_selection/mod.rs
@@ -1,3 +1,6 @@
mod actions;
+
+pub(crate) use actions::RecolorOpacity;
+pub(super) use actions::{level_applies, set_level};
mod constants;
mod helpers;
diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs
index 6422625f6..d49ddd493 100644
--- a/src/input/state/core/properties/entries.rs
+++ b/src/input/state/core/properties/entries.rs
@@ -1,8 +1,8 @@
use super::super::base::InputState;
use super::summary::{
PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_arrow_style,
- shape_color, shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background,
- shape_thickness, summarize_property,
+ shape_color, shape_fill_paint, shape_font_size, shape_opacity, shape_spotlight_magnification,
+ shape_text_background, shape_thickness, summarize_property,
};
use super::types::{SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue};
use super::utils::{approx_eq, color_label, color_rgba_eq};
@@ -91,7 +91,7 @@ impl InputState {
SelectionPropertyKind::Thickness,
&thickness_summary,
|v| format!("{v:.1}px"),
- SelectionPropertyValue::Number,
+ SelectionPropertyValue::Level,
));
} else {
let mut any_pressure = false;
@@ -135,14 +135,30 @@ impl InputState {
}
}
- let fill_summary = summarize_property(frame, ids, shape_fill, |a, b| a == b);
+ let opacity_summary = summarize_property(frame, ids, shape_opacity, approx_eq);
+ if opacity_summary.applicable {
+ entries.push(entry(
+ "Opacity",
+ SelectionPropertyKind::Opacity,
+ &opacity_summary,
+ |v| format!("{:.0}%", v * 100.0),
+ SelectionPropertyValue::Level,
+ ));
+ }
+
+ let fill_summary = summarize_property(frame, ids, shape_fill_paint, |a, b| match (a, b) {
+ (Some(a), Some(b)) => color_rgba_eq(a, b),
+ (a, b) => a.is_none() && b.is_none(),
+ });
if fill_summary.applicable {
entries.push(entry(
"Fill",
SelectionPropertyKind::Fill,
&fill_summary,
- |v| if v { "On" } else { "Off" }.to_string(),
- SelectionPropertyValue::Toggle,
+ |paint| {
+ paint.map_or_else(|| "None".to_string(), |color| color_label(palette, color))
+ },
+ SelectionPropertyValue::Fill,
));
}
@@ -257,6 +273,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: Color {
r: 1.0,
g: 0.0,
@@ -271,6 +288,7 @@ mod tests {
w: 10,
h: 10,
fill: false,
+ fill_color: None,
color: Color {
r: 0.0,
g: 0.0,
@@ -442,7 +460,7 @@ mod tests {
assert_eq!(
entry(&entries, "Thickness").state,
- SelectionPropertyValue::Number(Some(3.0))
+ SelectionPropertyValue::Level(Some(3.0))
);
assert_eq!(
entry(&entries, "Arrow head").state,
@@ -481,7 +499,7 @@ mod tests {
let entries = state.build_selection_property_entries(&[first, second]);
assert_eq!(
entry(&entries, "Thickness").state,
- SelectionPropertyValue::Number(None)
+ SelectionPropertyValue::Level(None)
);
assert!(entry(&entries, "Thickness").disabled);
}
diff --git a/src/input/state/core/properties/metrics.rs b/src/input/state/core/properties/metrics.rs
index e81d2f156..30d9aedf2 100644
--- a/src/input/state/core/properties/metrics.rs
+++ b/src/input/state/core/properties/metrics.rs
@@ -51,9 +51,14 @@ pub(crate) const STEP_BUTTON_WIDTH: f64 = 24.0;
pub(crate) const STEPPER_HEIGHT: f64 = 24.0;
pub(crate) const STEPPER_MIN_VALUE_WIDTH: f64 = 52.0;
pub(crate) const STEPPER_VALUE_PADDING: f64 = 16.0;
-pub(crate) const PREVIEW_WIDTH: f64 = 26.0;
-pub(crate) const PREVIEW_HEIGHT: f64 = 12.0;
-pub(crate) const PREVIEW_GAP: f64 = 8.0;
+
+/// A slider row: its track's hit band, the drawn track inside it, the thumb,
+/// the narrowest track a panel allows, and the gap before the readout.
+pub(crate) const SLIDER_HIT_HEIGHT: f64 = 24.0;
+pub(crate) const SLIDER_TRACK_HEIGHT: f64 = 4.0;
+pub(crate) const SLIDER_THUMB_RADIUS: f64 = 7.0;
+pub(crate) const SLIDER_MIN_TRACK: f64 = 96.0;
+pub(crate) const SLIDER_VALUE_GAP: f64 = 10.0;
pub(crate) const SWITCH_WIDTH: f64 = 34.0;
pub(crate) const SWITCH_HEIGHT: f64 = 20.0;
@@ -74,6 +79,21 @@ pub(crate) const LOCK_SIZE: f64 = 26.0;
/// The lock button's inset from the panel's top-right corner.
pub(crate) const LOCK_INSET: f64 = 8.0;
+/// The actions area: a divider, the ordering row, the Duplicate/Delete row,
+/// and the presets: a label line with Save at its end, then the slot chips
+/// across the full width.
+pub(crate) const ACTIONS_TOP_GAP: f64 = 12.0;
+pub(crate) const ACTION_BUTTON_HEIGHT: f64 = 28.0;
+pub(crate) const ACTION_ROW_GAP: f64 = 8.0;
+pub(crate) const ACTION_BUTTON_GAP: f64 = 6.0;
+pub(crate) const ACTIONS_BOTTOM: f64 = 6.0;
+pub(crate) const ACTIONS_HEIGHT: f64 =
+ ACTIONS_TOP_GAP + ACTION_BUTTON_HEIGHT * 4.0 + ACTION_ROW_GAP * 3.0 + ACTIONS_BOTTOM;
+/// The Save button at the end of the presets' label line.
+pub(crate) const PRESET_SAVE_WIDTH: f64 = 72.0;
+/// The "Order" label's column, where both rows of buttons start.
+pub(crate) const ACTIONS_LABEL_WIDTH: f64 = 60.0;
+
pub(crate) const FOOTER_HEIGHT: f64 = 30.0;
/// Height of the empty-state line when the selection has no properties.
pub(crate) const EMPTY_HEIGHT: f64 = 30.0;
diff --git a/src/input/state/core/properties/mod.rs b/src/input/state/core/properties/mod.rs
index 20ae5f274..c90f3990b 100644
--- a/src/input/state/core/properties/mod.rs
+++ b/src/input/state/core/properties/mod.rs
@@ -4,14 +4,16 @@ mod entries;
pub(crate) mod metrics;
mod panel;
mod panel_layout;
+mod slider;
mod state;
mod summary;
mod types;
mod utils;
+pub(crate) use apply_selection::RecolorOpacity;
pub use state::PropertiesPanelState;
pub use types::{
- PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock,
- PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry,
- SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel,
+ LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout,
+ PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry,
+ SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, ShapePropertiesPanel,
};
diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs
index 6939546a7..b1c4954fb 100644
--- a/src/input/state/core/properties/panel.rs
+++ b/src/input/state/core/properties/panel.rs
@@ -2,8 +2,8 @@ use super::super::base::InputState;
use super::metrics::MAX_SWATCHES;
use super::panel_layout::selection_panel_anchor;
use super::types::{
- PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch, SelectionPropertyEntry,
- SelectionPropertyValue, ShapePropertiesPanel,
+ PanelActions, PanelPreset, PropertiesPanelLayout, PropertiesPanelLock, PropertiesPanelSwatch,
+ SelectionPropertyEntry, SelectionPropertyValue, ShapePropertiesPanel,
};
use super::utils::format_timestamp;
use crate::draw::{Color, TextMeasurer};
@@ -23,6 +23,7 @@ struct PanelContents {
anchor_rect: Option,
entries: Vec,
swatches: Vec,
+ actions: PanelActions,
preview_color: Option,
multiple_selection: bool,
}
@@ -54,6 +55,12 @@ impl InputState {
}
pub fn close_properties_panel(&mut self) {
+ // A drag cut short by the panel closing keeps what it previewed, as
+ // one undo entry, rather than leaving the shapes changed off the
+ // record.
+ if self.is_properties_slider_dragging() {
+ self.finish_properties_slider_drag_with(&TextMeasurer::default());
+ }
if self.properties.close() {
self.dirty_tracker.mark_full();
self.needs_redraw = true;
@@ -98,12 +105,14 @@ impl InputState {
anchor_rect: contents.anchor_rect,
entries: contents.entries,
swatches: contents.swatches,
+ actions: contents.actions,
preview_color: contents.preview_color,
hover: None,
pressed: None,
keyboard_focus: None,
focus_visible: false,
scroll: 0.0,
+ preset_save_mode: false,
multiple_selection: contents.multiple_selection,
});
true
@@ -128,6 +137,7 @@ impl InputState {
panel.anchor_rect = contents.anchor_rect;
panel.entries = contents.entries;
panel.swatches = contents.swatches;
+ panel.actions = contents.actions;
panel.preview_color = contents.preview_color;
panel.multiple_selection = contents.multiple_selection;
@@ -241,6 +251,25 @@ impl InputState {
)
};
+ let actions = PanelActions {
+ can_raise: self.selection_can_step(true),
+ can_lower: self.selection_can_step(false),
+ can_edit: lock != PropertiesPanelLock::Locked,
+ can_save_preset: self.selection_preset_source().is_some(),
+ presets: self
+ .preset_slots
+ .presets()
+ .iter()
+ .take(self.preset_slots.slot_count())
+ .map(|preset| {
+ preset.as_ref().map(|preset| PanelPreset {
+ color: preset.preview_color(),
+ name: preset.name.clone(),
+ })
+ })
+ .collect(),
+ };
+
Some(PanelContents {
title,
subtitle,
@@ -250,6 +279,7 @@ impl InputState {
anchor_rect,
entries,
swatches,
+ actions,
preview_color,
multiple_selection: ids.len() > 1,
})
@@ -278,6 +308,7 @@ mod tests {
w,
h,
fill: false,
+ fill_color: None,
color: state.style.current_color,
thick: state.style.current_thickness,
})
@@ -410,6 +441,7 @@ mod tests {
rx: 10,
ry: 10,
fill: false,
+ fill_color: None,
color: state.style.current_color,
thick: state.style.current_thickness,
});
diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs
index 604d75146..31b060ad4 100644
--- a/src/input/state/core/properties/panel_layout/focus.rs
+++ b/src/input/state/core/properties/panel_layout/focus.rs
@@ -138,6 +138,7 @@ mod tests {
w: 30,
h: 40,
fill: false,
+ fill_color: None,
color: state.style.current_color,
thick: state.style.current_thickness,
});
diff --git a/src/input/state/core/properties/panel_layout/geometry.rs b/src/input/state/core/properties/panel_layout/geometry.rs
index ea4c015f6..7db2214c7 100644
--- a/src/input/state/core/properties/panel_layout/geometry.rs
+++ b/src/input/state/core/properties/panel_layout/geometry.rs
@@ -3,13 +3,15 @@
//! 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,
+ ACTION_BUTTON_GAP, ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_LABEL_WIDTH, ACTIONS_TOP_GAP,
+ BLOCK_BOTTOM, BLOCK_GAP, BLOCK_LABEL_LINE, BLOCK_TOP, BODY_FONT, COLUMN_SPACING,
+ PRESET_SAVE_WIDTH, ROW_HEIGHT, ROW_INSET, SEGMENT_HEIGHT, SEGMENT_PAD, SLIDER_HIT_HEIGHT,
+ SLIDER_THUMB_RADIUS, SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_HEIGHT, STYLE_BUTTON_GAP,
+ STYLE_BUTTON_HEIGHT, SWATCH_GAP, SWATCH_ITEMS_PER_LINE, SWATCH_LINE_GAP, SWATCH_SIZE,
+ SWITCH_HEIGHT, SWITCH_WIDTH, TITLE_FONT,
};
use super::super::types::{
- PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock,
+ PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock,
PropertiesRowControl, PropertiesRowGeometry, SelectionPropertyEntry, SelectionPropertyKind,
SelectionPropertyValue, ShapePropertiesPanel,
};
@@ -27,22 +29,38 @@ enum RowShape {
fn row_shape(entry: &SelectionPropertyEntry) -> RowShape {
match entry.state {
- SelectionPropertyValue::Color(_) | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block,
- SelectionPropertyValue::Number(_)
+ SelectionPropertyValue::Color(_)
+ | SelectionPropertyValue::Fill(_)
+ | SelectionPropertyValue::ArrowStyle(_) => RowShape::Block,
+ SelectionPropertyValue::Level(_)
+ | 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)
+/// Whether a swatch row leads with a "no fill" cell: the fill row does.
+fn leads_with_no_fill(entry: &SelectionPropertyEntry) -> bool {
+ matches!(entry.state, SelectionPropertyValue::Fill(_))
}
-/// Width of the swatch grid's widest line.
-pub(in crate::input::state::core::properties) fn swatch_grid_width(swatches: usize) -> f64 {
- let items = (swatches + 1).min(SWATCH_ITEMS_PER_LINE) as f64;
+/// Cells in a swatch row: its swatches, the trailing "more colors" button,
+/// and the fill row's leading "no fill".
+pub(in crate::input::state::core::properties) fn swatch_cells(
+ entry: &SelectionPropertyEntry,
+ swatches: usize,
+) -> usize {
+ swatches + 1 + usize::from(leads_with_no_fill(entry))
+}
+
+fn swatch_lines(cells: usize) -> usize {
+ cells.div_ceil(SWATCH_ITEMS_PER_LINE)
+}
+
+/// Width of a swatch grid's widest line.
+pub(in crate::input::state::core::properties) fn swatch_grid_width(cells: usize) -> f64 {
+ let items = cells.min(SWATCH_ITEMS_PER_LINE) as f64;
items * SWATCH_SIZE + (items - 1.0) * SWATCH_GAP
}
@@ -51,8 +69,8 @@ pub(in crate::input::state::core::properties) fn row_height(
swatches: usize,
) -> f64 {
let control_height = match entry.state {
- SelectionPropertyValue::Color(_) => {
- let lines = swatch_lines(swatches) as f64;
+ SelectionPropertyValue::Color(_) | SelectionPropertyValue::Fill(_) => {
+ let lines = swatch_lines(swatch_cells(entry, swatches)) as f64;
lines * SWATCH_SIZE + (lines - 1.0) * SWATCH_LINE_GAP
}
SelectionPropertyValue::ArrowStyle(_) => STYLE_BUTTON_HEIGHT,
@@ -108,14 +126,6 @@ pub(in crate::input::state::core::properties) fn balanced_column_budget(
high
}
-/// Whether a stepper row draws a stroke at its current thickness.
-pub(in crate::input::state::core::properties) fn shows_thickness_preview(
- entry: &SelectionPropertyEntry,
-) -> bool {
- entry.kind == SelectionPropertyKind::Thickness
- && matches!(entry.state, SelectionPropertyValue::Number(Some(_)))
-}
-
impl PropertiesPanelLayout {
pub fn rect(&self) -> PanelRect {
PanelRect::new(self.origin_x, self.origin_y, self.width, self.height)
@@ -152,6 +162,58 @@ impl PropertiesPanelLayout {
.is_none_or(|scroll| y >= self.rows_top && y < scroll.viewport_bottom)
}
+ /// The actions area's buttons: the four ordering buttons, and Duplicate
+ /// and Delete under them, both after the label column; then Save at the
+ /// end of the presets' label line, and the preset slots under it across
+ /// the full content width, which gives each chip room for its dot and
+ /// number.
+ pub fn action_buttons(&self) -> Vec<(PanelAction, PanelRect)> {
+ let left = self.content_x() + ACTIONS_LABEL_WIDTH;
+ let width = self.content_right() - left;
+ let row = |top: f64, actions: &[PanelAction]| {
+ let count = actions.len() as f64;
+ let button = (width - ACTION_BUTTON_GAP * (count - 1.0)) / count;
+ actions
+ .iter()
+ .enumerate()
+ .map(|(index, action)| {
+ let x = left + index as f64 * (button + ACTION_BUTTON_GAP);
+ (
+ *action,
+ PanelRect::new(x, top, button, ACTION_BUTTON_HEIGHT),
+ )
+ })
+ .collect::>()
+ };
+ let order_top = self.actions_top + ACTIONS_TOP_GAP;
+ let edit_top = order_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP;
+ let mut buttons = row(order_top, &PanelAction::ORDER);
+ buttons.extend(row(edit_top, &PanelAction::EDIT));
+
+ let preset_label_top = edit_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP;
+ let chips_top = preset_label_top + ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP;
+ let slots = self.preset_slots.max(1) as f64;
+ let chip =
+ (self.content_right() - self.content_x() - ACTION_BUTTON_GAP * (slots - 1.0)) / slots;
+ buttons.extend((0..self.preset_slots).map(|index| {
+ let x = self.content_x() + index as f64 * (chip + ACTION_BUTTON_GAP);
+ (
+ PanelAction::Preset(index + 1),
+ PanelRect::new(x, chips_top, chip, ACTION_BUTTON_HEIGHT),
+ )
+ }));
+ buttons.push((
+ PanelAction::SavePreset,
+ PanelRect::new(
+ self.content_right() - PRESET_SAVE_WIDTH,
+ preset_label_top,
+ PRESET_SAVE_WIDTH,
+ ACTION_BUTTON_HEIGHT,
+ ),
+ ));
+ buttons
+ }
+
/// Left edge of the content of `column`.
pub fn column_x(&self, column: usize) -> f64 {
self.content_x() + column as f64 * (self.column_width + COLUMN_SPACING)
@@ -208,7 +270,7 @@ impl PropertiesPanelLayout {
SelectionPropertyValue::ArrowStyle(_) => {
self.arrow_style_buttons(left, control_top)
}
- _ => swatch_grid(left, control_top, swatches),
+ _ => swatch_grid(left, control_top, swatches, leads_with_no_fill(entry)),
};
return PropertiesRowGeometry {
index,
@@ -254,6 +316,24 @@ impl PropertiesPanelLayout {
);
PropertiesRowControl::ArrowHead { well, start, end }
}
+ SelectionPropertyValue::Level(_) => {
+ let value = PanelRect::new(
+ right - self.slider_value_width,
+ center_y - SLIDER_HIT_HEIGHT / 2.0,
+ self.slider_value_width,
+ SLIDER_HIT_HEIGHT,
+ );
+ let track_x = left + self.label_column;
+ PropertiesRowControl::Slider {
+ track: PanelRect::new(
+ track_x,
+ center_y - SLIDER_HIT_HEIGHT / 2.0,
+ (value.x - SLIDER_VALUE_GAP - track_x).max(SLIDER_THUMB_RADIUS * 2.0),
+ SLIDER_HIT_HEIGHT,
+ ),
+ value,
+ }
+ }
_ => {
let total = STEP_BUTTON_WIDTH * 2.0 + self.stepper_value_width;
let x = right - total;
@@ -262,20 +342,7 @@ impl PropertiesPanelLayout {
let value =
PanelRect::new(down.right(), y, self.stepper_value_width, STEPPER_HEIGHT);
let up = PanelRect::new(value.right(), y, STEP_BUTTON_WIDTH, STEPPER_HEIGHT);
- let preview = shows_thickness_preview(entry).then(|| {
- PanelRect::new(
- x - PREVIEW_GAP - PREVIEW_WIDTH,
- center_y - PREVIEW_HEIGHT / 2.0,
- PREVIEW_WIDTH,
- PREVIEW_HEIGHT,
- )
- });
- PropertiesRowControl::Stepper {
- down,
- value,
- up,
- preview,
- }
+ PropertiesRowControl::Stepper { down, value, up }
}
};
@@ -319,6 +386,13 @@ impl PropertiesPanelLayout {
if self.title_rect().contains(x, y) {
return Some(PropertiesPanelHit::Title);
}
+ if y >= self.actions_top {
+ return self
+ .action_buttons()
+ .into_iter()
+ .find(|(_, rect)| rect.contains(x, y))
+ .map(|(action, _)| PropertiesPanelHit::Action(action));
+ }
// Rows scrolled out of the viewport are clipped, so they take no clicks.
if !self.rows_visible_at(y) {
return None;
@@ -330,9 +404,15 @@ impl PropertiesPanelLayout {
.find(|row| row.rect.contains(x, y))?;
let index = row.index;
let hit = match &row.control {
- PropertiesRowControl::Swatches { swatches, more } => {
- if more.contains(x, y) {
+ PropertiesRowControl::Swatches {
+ swatches,
+ none,
+ more,
+ } => {
+ if more.is_some_and(|more| more.contains(x, y)) {
Some(PropertiesPanelHit::MoreColors(index))
+ } else if none.is_some_and(|none| none.contains(x, y)) {
+ Some(PropertiesPanelHit::NoFill(index))
} else {
swatches
.iter()
@@ -343,6 +423,9 @@ impl PropertiesPanelLayout {
})
}
}
+ PropertiesRowControl::Slider { track, .. } => track
+ .contains(x, y)
+ .then_some(PropertiesPanelHit::Slider(index)),
PropertiesRowControl::Stepper { down, up, .. } => {
if down.contains(x, y) {
Some(PropertiesPanelHit::StepDown(index))
@@ -391,6 +474,13 @@ impl PropertiesPanelLayout {
match hit {
PropertiesPanelHit::Title => return Some(self.title_rect()),
PropertiesPanelHit::Lock => return Some(self.lock),
+ PropertiesPanelHit::Action(action) => {
+ return self
+ .action_buttons()
+ .into_iter()
+ .find(|(candidate, _)| *candidate == action)
+ .map(|(_, rect)| rect);
+ }
_ => {}
}
let row = self.rows(panel).into_iter().nth(hit.row()?)?;
@@ -401,8 +491,10 @@ impl PropertiesPanelLayout {
PropertiesRowControl::Swatches { swatches, .. },
) => *swatches.get(index)?,
(PropertiesPanelHit::MoreColors(_), PropertiesRowControl::Swatches { more, .. }) => {
- more
+ more?
}
+ (PropertiesPanelHit::NoFill(_), PropertiesRowControl::Swatches { none, .. }) => none?,
+ (PropertiesPanelHit::Slider(_), PropertiesRowControl::Slider { track, .. }) => track,
(PropertiesPanelHit::StepDown(_), PropertiesRowControl::Stepper { down, .. }) => down,
(PropertiesPanelHit::StepUp(_), PropertiesRowControl::Stepper { up, .. }) => up,
(PropertiesPanelHit::Toggle(_), PropertiesRowControl::Toggle { switch }) => switch,
@@ -429,7 +521,9 @@ impl PropertiesPanelLayout {
}
}
-fn swatch_grid(x: f64, top: f64, swatches: usize) -> PropertiesRowControl {
+/// A swatch row's cells: an optional leading "no fill", the swatches, and a
+/// trailing "more colors" button that opens the full color picker.
+fn swatch_grid(x: f64, top: f64, swatches: usize, no_fill: bool) -> PropertiesRowControl {
let cell = |item: usize| {
let line = item / SWATCH_ITEMS_PER_LINE;
let column = item % SWATCH_ITEMS_PER_LINE;
@@ -440,9 +534,11 @@ fn swatch_grid(x: f64, top: f64, swatches: usize) -> PropertiesRowControl {
SWATCH_SIZE,
)
};
+ let first = usize::from(no_fill);
PropertiesRowControl::Swatches {
- swatches: (0..swatches).map(cell).collect(),
- more: cell(swatches),
+ swatches: (first..first + swatches).map(cell).collect(),
+ none: no_fill.then(|| cell(0)),
+ more: Some(cell(first + swatches)),
}
}
@@ -461,7 +557,9 @@ impl ShapePropertiesPanel {
/// The swatch holding the row's single color, which gets the selection
/// ring. `None` for a mixed, locked, or custom color.
pub fn current_swatch(&self, entry: &SelectionPropertyEntry) -> Option {
- let SelectionPropertyValue::Color(Some(color)) = entry.state else {
+ let (SelectionPropertyValue::Color(Some(color))
+ | SelectionPropertyValue::Fill(Some(Some(color)))) = entry.state
+ else {
return None;
};
palette_position(self.swatches.iter().map(|swatch| swatch.color), color)
@@ -484,7 +582,40 @@ impl ShapePropertiesPanel {
PropertiesPanelHit::Swatch { index, .. } => {
self.swatches.get(index).map(|swatch| swatch.label.clone())
}
- PropertiesPanelHit::MoreColors(_) => Some("More colors…".to_string()),
+ PropertiesPanelHit::MoreColors(row) => Some(
+ match self.entries.get(row).map(|entry| entry.kind) {
+ Some(SelectionPropertyKind::Fill) => "More fill colors…",
+ _ => "More colors…",
+ }
+ .to_string(),
+ ),
+ PropertiesPanelHit::NoFill(_) => Some("No fill".to_string()),
+ PropertiesPanelHit::Action(action) => match action {
+ PanelAction::ToBack => Some("Send to back".to_string()),
+ PanelAction::Backward => Some("Send backward".to_string()),
+ PanelAction::Forward => Some("Bring forward".to_string()),
+ PanelAction::ToFront => Some("Bring to front".to_string()),
+ PanelAction::Duplicate | PanelAction::Delete => None,
+ PanelAction::SavePreset => Some(
+ if self.preset_save_mode {
+ "Pick a slot to save into"
+ } else {
+ "Save the selection's style as a preset"
+ }
+ .to_string(),
+ ),
+ PanelAction::Preset(slot) => Some(if self.preset_save_mode {
+ format!("Save to preset {slot}")
+ } else {
+ match self.actions.presets.get(slot - 1) {
+ Some(Some(preset)) => match &preset.name {
+ Some(name) => format!("Apply preset {slot}: {name}"),
+ None => format!("Apply preset {slot}"),
+ },
+ _ => format!("Preset {slot} is empty"),
+ }
+ }),
+ },
_ => None,
}
}
diff --git a/src/input/state/core/properties/panel_layout/interaction.rs b/src/input/state/core/properties/panel_layout/interaction.rs
index 1ff21ca0e..d9b041a58 100644
--- a/src/input/state/core/properties/panel_layout/interaction.rs
+++ b/src/input/state/core/properties/panel_layout/interaction.rs
@@ -22,6 +22,11 @@ impl InputState {
) -> Option {
let hit = self.properties_panel_hit_at(x, y)?;
let panel = self.properties.panel.as_ref()?;
+ if let PropertiesPanelHit::Action(action) = hit
+ && !panel.action_enabled(action)
+ {
+ return None;
+ }
match hit.row() {
Some(row) if panel.entries.get(row).is_none_or(|entry| entry.disabled) => None,
_ => Some(hit),
@@ -71,14 +76,39 @@ impl InputState {
self.update_properties_panel_hover_from_pointer_internal(x, y, true);
}
+ /// Pointer motion over an open panel: a slider drag follows it, and
+ /// otherwise the hover does.
+ pub(crate) fn move_properties_panel_pointer_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ x: i32,
+ y: i32,
+ ) {
+ if self.is_properties_slider_dragging() {
+ self.drag_properties_slider_with(measurer, x);
+ } else {
+ self.update_properties_panel_hover_from_pointer(x, y);
+ }
+ }
+
/// A primary press at `(x, y)`. Returns false when it landed off the
/// panel. The pressed part is remembered for the release, and its row
/// takes (quiet) keyboard focus so arrow keys continue from the click.
- pub(crate) fn press_properties_panel_at(&mut self, x: i32, y: i32) -> bool {
+ pub(crate) fn press_properties_panel_at_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ x: i32,
+ y: i32,
+ ) -> bool {
if !self.properties_panel_contains(x, y) {
return false;
}
let hit = self.properties_panel_active_hit_at(x, y);
+ // A slider acts on the press itself: the value jumps to the pointer
+ // and follows it until the release.
+ if let Some(PropertiesPanelHit::Slider(row)) = hit {
+ let _ = self.begin_properties_slider_drag_with(measurer, row, x);
+ }
if let Some(panel) = self.properties.panel.as_mut() {
panel.pressed = hit;
if let Some(row) = hit.and_then(PropertiesPanelHit::row) {
@@ -103,6 +133,10 @@ impl InputState {
.panel
.as_mut()
.and_then(|panel| panel.pressed.take());
+ if self.is_properties_slider_dragging() {
+ self.finish_properties_slider_drag_with(measurer);
+ return;
+ }
match pressed {
Some(hit) => {
if self.properties_panel_active_hit_at(x, y) == Some(hit) {
@@ -168,4 +202,19 @@ impl InputState {
self.needs_redraw = true;
true
}
+
+ /// Disarms saving into a preset slot. Returns false when it was not
+ /// armed, so Escape can fall through to closing the panel.
+ pub(crate) fn cancel_properties_preset_save(&mut self) -> bool {
+ let Some(panel) = self.properties.panel.as_mut() else {
+ return false;
+ };
+ if !panel.preset_save_mode {
+ return false;
+ }
+ panel.preset_save_mode = false;
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ true
+ }
}
diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs
index 37e0b7dc5..ceb9c4945 100644
--- a/src/input/state/core/properties/panel_layout/layout.rs
+++ b/src/input/state/core/properties/panel_layout/layout.rs
@@ -3,19 +3,20 @@ use cairo::FontWeight;
use super::super::super::base::InputState;
use super::super::metrics::{
- BODY_FONT, COLUMN_GAP, COLUMN_SPACING, EMPTY_HEIGHT, FOOTER_HEIGHT, HEADER_GAP, LOCK_INSET,
- LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, PREVIEW_GAP, PREVIEW_WIDTH,
- ROW_HEIGHT, ROWS_GAP, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, SEGMENT_TEXT_PADDING,
- STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING, STYLE_BUTTON_GAP,
- STYLE_BUTTON_MIN_WIDTH, SUBTITLE_FONT, SUBTITLE_STEP, SWITCH_VALUE_GAP, SWITCH_WIDTH,
- TITLE_FONT, TOOLTIP_FONT, TOOLTIP_GAP, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y, VALUE_FONT,
- text_style,
+ ACTIONS_HEIGHT, BODY_FONT, COLUMN_GAP, COLUMN_SPACING, EMPTY_HEIGHT, FOOTER_HEIGHT, HEADER_GAP,
+ LOCK_INSET, LOCK_SIZE, MIN_WIDTH, PADDING_BOTTOM, PADDING_TOP, PADDING_X, ROW_HEIGHT, ROWS_GAP,
+ SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SEGMENT_PAD, SEGMENT_TEXT_PADDING, SLIDER_MIN_TRACK,
+ SLIDER_VALUE_GAP, STEP_BUTTON_WIDTH, STEPPER_MIN_VALUE_WIDTH, STEPPER_VALUE_PADDING,
+ STYLE_BUTTON_GAP, STYLE_BUTTON_MIN_WIDTH, SUBTITLE_FONT, SUBTITLE_STEP, SWITCH_VALUE_GAP,
+ SWITCH_WIDTH, TITLE_FONT, TOOLTIP_FONT, TOOLTIP_GAP, TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y,
+ VALUE_FONT, text_style,
};
use super::super::types::{
- PanelRect, PanelScroll, PropertiesPanelLayout, SelectionPropertyValue, ShapePropertiesPanel,
+ PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, SelectionPropertyValue,
+ ShapePropertiesPanel,
};
use super::geometry::{
- balanced_column_budget, column_slots, row_height, shows_thickness_preview, swatch_grid_width,
+ balanced_column_budget, column_slots, row_height, swatch_cells, swatch_grid_width,
};
use super::{PANEL_ANCHOR_GAP, PANEL_MARGIN};
use crate::draw::ArrowStyle;
@@ -28,8 +29,14 @@ struct Measured {
content_width: f64,
stepper_value_width: f64,
head_segment_width: f64,
+ label_column: f64,
+ slider_value_width: f64,
}
+/// Values a slider readout must have room for, whatever it shows now, so the
+/// track keeps its length while a drag changes the readout.
+const SLIDER_READOUT_SAMPLES: [&str; 4] = ["50.0px", "100%", "Mixed", "Locked"];
+
fn measure_panel(
engine: &UiTextEngine,
ctx: &CairoContext,
@@ -50,6 +57,10 @@ fn measure_panel(
let mut label_width: f64 = 0.0;
let mut stepper_text_width: f64 = 0.0;
let mut toggle_value_width: f64 = 0.0;
+ let mut slider_text_width: f64 = SLIDER_READOUT_SAMPLES
+ .into_iter()
+ .map(|text| width(VALUE_FONT, FontWeight::Normal, text))
+ .fold(0.0, f64::max);
for entry in &panel.entries {
label_width = label_width.max(width(BODY_FONT, FontWeight::Normal, &entry.label));
match entry.state {
@@ -64,9 +75,15 @@ fn measure_panel(
toggle_value_width =
toggle_value_width.max(width(VALUE_FONT, FontWeight::Normal, &entry.value));
}
+ SelectionPropertyValue::Level(_) => {
+ slider_text_width =
+ slider_text_width.max(width(VALUE_FONT, FontWeight::Normal, &entry.value));
+ }
_ => {}
}
}
+ let label_column = (label_width + COLUMN_GAP).ceil();
+ let slider_value_width = slider_text_width.ceil();
let head_text_width = ["Start", "End"]
.into_iter()
.map(|text| width(VALUE_FONT, FontWeight::Normal, text))
@@ -93,22 +110,17 @@ fn measure_panel(
.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::Color(_) | SelectionPropertyValue::Fill(_) => {
+ label_width.max(swatch_grid_width(swatch_cells(entry, 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::Level(_) => {
+ label_column + SLIDER_MIN_TRACK + SLIDER_VALUE_GAP + slider_value_width
}
- SelectionPropertyValue::PressureVaries => {
+ SelectionPropertyValue::Number(_) | SelectionPropertyValue::PressureVaries => {
label_width + COLUMN_GAP + STEP_BUTTON_WIDTH * 2.0 + stepper_value_width
}
SelectionPropertyValue::Toggle(_) => {
@@ -126,6 +138,8 @@ fn measure_panel(
content_width,
stepper_value_width,
head_segment_width,
+ label_column,
+ slider_value_width,
}
}
@@ -218,7 +232,7 @@ impl InputState {
} else {
0.0
};
- let panel_height = (rows_top + fit.height + tail + PADDING_BOTTOM).ceil();
+ let panel_height = (rows_top + fit.height + ACTIONS_HEIGHT + tail + PADDING_BOTTOM).ceil();
let screen_w = screen_width as f64;
let screen_h = screen_height as f64;
@@ -250,9 +264,13 @@ impl InputState {
),
divider_y: origin_y + divider,
rows_top: origin_y + rows_top,
- footer_top: has_footer.then_some(origin_y + rows_top + fit.height),
+ actions_top: origin_y + rows_top + fit.height,
+ preset_slots: panel.actions.presets.len(),
+ footer_top: has_footer.then_some(origin_y + rows_top + fit.height + ACTIONS_HEIGHT),
stepper_value_width: measured.stepper_value_width,
head_segment_width: measured.head_segment_width,
+ label_column: measured.label_column,
+ slider_value_width: measured.slider_value_width,
column_width,
column_budget: fit.budget,
scroll: scroll.map(|(offset, max_offset)| PanelScroll {
@@ -333,7 +351,7 @@ impl InputState {
let x = (center_x - width / 2.0).clamp(PANEL_MARGIN, max_x);
let below = anchor.bottom() + TOOLTIP_GAP;
let above = anchor.y - TOOLTIP_GAP - height;
- let header = hit.row().is_none();
+ let header = matches!(hit, PropertiesPanelHit::Title | PropertiesPanelHit::Lock);
let fits_below = below + height <= screen_h - PANEL_MARGIN;
let fits_above = above >= PANEL_MARGIN;
let y = if (header && fits_above) || !fits_below {
@@ -357,7 +375,8 @@ struct RowFit {
scrolled_content: Option,
}
-/// Fits the rows under a header of `rows_top` onto the screen. In order: one
+/// Fits the rows, between a header of `rows_top` and the fixed actions area,
+/// onto the screen. In order: one
/// column with the keyboard hints; one without them; as few even columns as
/// fit both the height and the width; and, when no arrangement fits, one
/// column scrolling inside the panel. Only the last gives up the wheel for
@@ -386,10 +405,10 @@ fn fit_rows(
scrolled_content: None,
};
- if rows_top + total + FOOTER_HEIGHT + PADDING_BOTTOM <= available_h {
+ if rows_top + total + ACTIONS_HEIGHT + FOOTER_HEIGHT + PADDING_BOTTOM <= available_h {
return single(true);
}
- let band = available_h - rows_top - PADDING_BOTTOM;
+ let band = available_h - rows_top - ACTIONS_HEIGHT - PADDING_BOTTOM;
if total <= band {
return single(false);
}
diff --git a/src/input/state/core/properties/panel_layout/tests.rs b/src/input/state/core/properties/panel_layout/tests.rs
index eb9090291..4f74f5700 100644
--- a/src/input/state/core/properties/panel_layout/tests.rs
+++ b/src/input/state/core/properties/panel_layout/tests.rs
@@ -1,4 +1,4 @@
-use super::super::metrics::{FOOTER_HEIGHT, PADDING_BOTTOM};
+use super::super::metrics::{ACTIONS_HEIGHT, FOOTER_HEIGHT, PADDING_BOTTOM};
use super::super::types::{
PanelRect, PropertiesPanelHit, PropertiesRowControl, PropertiesRowGeometry,
};
@@ -7,7 +7,7 @@ use crate::draw::{ArrowStyle, Shape, ShapeId, TextMeasurer};
use crate::input::state::InputState;
use crate::ui_text::UiTextEngine;
-const SCREEN: (u32, u32) = (800, 600);
+const SCREEN: (u32, u32) = (800, 900);
fn arrow(state: &mut InputState) -> ShapeId {
state.boards.active_frame_mut().add_shape(Shape::Arrow {
@@ -84,7 +84,11 @@ fn inside(outer: PanelRect, inner: PanelRect) -> bool {
fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelHit)> {
let index = row.index;
match &row.control {
- PropertiesRowControl::Swatches { swatches, more } => swatches
+ PropertiesRowControl::Swatches {
+ swatches,
+ none,
+ more,
+ } => swatches
.iter()
.enumerate()
.map(|(swatch, rect)| {
@@ -96,7 +100,8 @@ fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelH
},
)
})
- .chain([(*more, PropertiesPanelHit::MoreColors(index))])
+ .chain(none.map(|rect| (rect, PropertiesPanelHit::NoFill(index))))
+ .chain(more.map(|rect| (rect, PropertiesPanelHit::MoreColors(index))))
.collect(),
PropertiesRowControl::Stepper { down, up, .. } => vec![
(*down, PropertiesPanelHit::StepDown(index)),
@@ -105,6 +110,9 @@ fn control_hits(row: &PropertiesRowGeometry) -> Vec<(PanelRect, PropertiesPanelH
PropertiesRowControl::Toggle { switch } => {
vec![(*switch, PropertiesPanelHit::Toggle(index))]
}
+ PropertiesRowControl::Slider { track, .. } => {
+ vec![(*track, PropertiesPanelHit::Slider(index))]
+ }
PropertiesRowControl::ArrowHead { start, end, .. } => vec![
(
*start,
@@ -155,8 +163,9 @@ fn rows_stack_from_the_divider_to_the_footer() {
);
top = row.rect.bottom();
}
+ assert_eq!(layout.actions_top, top, "the actions sit under the rows");
let footer_top = layout.footer_top.expect("footer");
- assert_eq!(footer_top, top);
+ assert_eq!(footer_top, top + ACTIONS_HEIGHT);
assert_eq!(
layout.origin_y + layout.height,
(footer_top + FOOTER_HEIGHT + PADDING_BOTTOM).ceil()
@@ -174,6 +183,7 @@ fn every_control_is_hit_where_it_is_drawn_and_stays_in_its_row() {
w: 40,
h: 40,
fill: false,
+ fill_color: None,
color: PALETTE_RED,
thick: 2.0,
});
@@ -344,6 +354,7 @@ fn a_selection_taller_than_the_screen_flows_into_columns() {
w: 40,
h: 40,
fill: false,
+ fill_color: None,
color: PALETTE_RED,
thick: 2.0,
});
@@ -354,9 +365,17 @@ fn a_selection_taller_than_the_screen_flows_into_columns() {
spotlight(&mut state),
];
open_panel(ids, &mut state);
+ lay_out(&mut state, (SCREEN.0, 2000));
let single = *state.properties_panel_layout().expect("layout");
+ assert_eq!(
+ single.column_budget,
+ f64::INFINITY,
+ "one column on a tall screen"
+ );
- lay_out(&mut state, (SCREEN.0, 480));
+ // Wide enough for as many columns as the rows need at this height.
+ let wide = 1400;
+ lay_out(&mut state, (wide, 480));
let layout = *state.properties_panel_layout().expect("layout");
let rows = rows(&state);
@@ -364,7 +383,7 @@ fn a_selection_taller_than_the_screen_flows_into_columns() {
rows.iter().map(|row| row.content_x.to_bits()).collect();
assert!(columns.len() > 1, "the rows spread over columns");
assert!(layout.width > single.width);
- assert!(layout.origin_x + layout.width <= SCREEN.0 as f64 - 12.0 + 1e-9);
+ assert!(layout.origin_x + layout.width <= f64::from(wide) - 12.0 + 1e-9);
assert_all_reachable(&state, 480.0);
let heights: Vec = columns
.iter()
@@ -391,6 +410,7 @@ fn four_kinds(state: &mut InputState) -> Vec {
w: 40,
h: 40,
fill: false,
+ fill_color: None,
color: PALETTE_RED,
thick: 2.0,
});
@@ -442,15 +462,17 @@ fn when_columns_would_leave_the_screen_the_rows_scroll_instead() {
let first = assert_visible_rows_reachable(&state, (480.0, 360.0));
assert!(first.contains(&0));
- // A row clipped out of the viewport takes no clicks.
+ // A row clipped out of the viewport takes no clicks; what is drawn
+ // there (the actions, or nothing) does.
let hidden = rows(&state)
.into_iter()
.find(|row| row.rect.y >= layout.rows_viewport().bottom())
.expect("a row below the viewport");
let (hx, hy) = hidden.rect.center();
- assert_eq!(
- layout.hit_at(state.properties_panel().unwrap(), hx, hy),
- None
+ let hit = layout.hit_at(state.properties_panel().unwrap(), hx, hy);
+ assert!(
+ hit.and_then(PropertiesPanelHit::row).is_none(),
+ "{hit:?} reached a clipped row"
);
// The wheel scrolls rather than stepping a row, down to the last row.
@@ -512,7 +534,7 @@ fn scrolling_after_a_click_moves_hover_off_the_control_that_scrolled_away() {
// A click remembers the row quietly, and the pointer rests on the swatch.
state.update_pointer_position(x, y);
- assert!(state.press_properties_panel_at(x, y));
+ assert!(state.press_properties_panel_at_with(&measurer, x, y));
state.release_properties_panel_at_with(&measurer, x, y);
lay_out(&mut state, screen);
state.update_properties_panel_hover_from_pointer(x, y);
diff --git a/src/input/state/core/properties/slider.rs b/src/input/state/core/properties/slider.rs
new file mode 100644
index 000000000..6fbbe1f96
--- /dev/null
+++ b/src/input/state/core/properties/slider.rs
@@ -0,0 +1,124 @@
+//! Dragging a properties-panel slider: the shapes follow the thumb live, and
+//! the whole drag lands as one undo entry when it ends.
+
+use super::super::base::InputState;
+use super::apply_selection::{level_applies, set_level};
+use super::types::{PropertiesRowControl, SelectionPropertyKind};
+use crate::draw::frame::ShapeSnapshot;
+use crate::draw::{ShapeId, TextMeasurer};
+use crate::input::state::core::editing::CanvasEdit;
+
+/// A slider drag in progress: the shapes as they were when it began, so every
+/// step previews from them and the release commits against them.
+#[derive(Debug)]
+pub(crate) struct SliderDrag {
+ row: usize,
+ kind: SelectionPropertyKind,
+ snapshots: Vec<(ShapeId, ShapeSnapshot)>,
+ value: Option,
+}
+
+impl InputState {
+ pub(crate) fn is_properties_slider_dragging(&self) -> bool {
+ self.properties.slider_drag.is_some()
+ }
+
+ /// Starts dragging the slider on `row`, with the pointer at `x`, which
+ /// moves the value there at once. Returns false when the row has no
+ /// editable slider.
+ pub(crate) fn begin_properties_slider_drag_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ row: usize,
+ x: i32,
+ ) -> bool {
+ let Some(entry) = self
+ .properties
+ .panel
+ .as_ref()
+ .and_then(|panel| panel.entries.get(row))
+ .filter(|entry| !entry.disabled && entry.kind.level_range().is_some())
+ else {
+ return false;
+ };
+ let kind = entry.kind;
+
+ self.finish_active_arrow_bend();
+ let frame = self.boards.active_frame();
+ let editable: Vec = self
+ .selected_shape_ids()
+ .iter()
+ .copied()
+ .filter(|id| {
+ frame
+ .shape(*id)
+ .is_some_and(|drawn| !drawn.locked && level_applies(kind, &drawn.shape))
+ })
+ .collect();
+ let snapshots = CanvasEdit::capture(frame, &editable).into_snapshots();
+ self.properties.slider_drag = Some(SliderDrag {
+ row,
+ kind,
+ snapshots,
+ value: None,
+ });
+
+ self.drag_properties_slider_with(measurer, x);
+ true
+ }
+
+ /// Moves the dragged slider's value to the pointer at `x`, previewing it
+ /// on the shapes without recording history.
+ pub(crate) fn drag_properties_slider_with(&mut self, measurer: &TextMeasurer, x: i32) {
+ let Some(mut drag) = self.properties.slider_drag.take() else {
+ return;
+ };
+ let track = self.properties_slider_track(drag.row);
+ let value = track
+ .zip(drag.kind.level_range())
+ .map(|(track, range)| range.value_at_x(track, f64::from(x)));
+
+ if let Some(value) = value
+ && drag.value != Some(value)
+ {
+ drag.value = Some(value);
+ let kind = drag.kind;
+ let effects = CanvasEdit::borrow_snapshots(&drag.snapshots).preview(
+ self.boards.active_frame_mut(),
+ measurer,
+ |shape, _| set_level(kind, shape, value),
+ );
+ self.apply_edit_effects(measurer, effects);
+ self.properties.slider_drag = Some(drag);
+ self.refresh_properties_panel_with(measurer);
+ } else {
+ self.properties.slider_drag = Some(drag);
+ }
+ }
+
+ /// Ends a slider drag, recording everything it changed as one undo entry.
+ pub(crate) fn finish_properties_slider_drag_with(&mut self, measurer: &TextMeasurer) {
+ let Some(drag) = self.properties.slider_drag.take() else {
+ return;
+ };
+
+ let effects = CanvasEdit::from_snapshots(drag.snapshots).commit(
+ self.boards.active_frame_mut(),
+ self.history_limits.undo_stack_limit(),
+ );
+ self.apply_edit_effects(measurer, effects);
+
+ if self.is_properties_panel_open() {
+ self.refresh_properties_panel_with(measurer);
+ }
+ }
+
+ fn properties_slider_track(&self, row: usize) -> Option {
+ let panel = self.properties.panel.as_ref()?;
+ let layout = self.properties.layout.as_ref()?;
+ match layout.rows(panel).into_iter().nth(row)?.control {
+ PropertiesRowControl::Slider { track, .. } => Some(track),
+ _ => None,
+ }
+ }
+}
diff --git a/src/input/state/core/properties/state.rs b/src/input/state/core/properties/state.rs
index 956c07908..278ffdd2c 100644
--- a/src/input/state/core/properties/state.rs
+++ b/src/input/state/core/properties/state.rs
@@ -7,6 +7,7 @@ pub struct PropertiesPanelState {
pub(in crate::input::state) layout: Option,
pub(super) pending_hover_recalc: bool,
pub(super) needs_refresh: bool,
+ pub(super) slider_drag: Option,
}
impl PropertiesPanelState {
@@ -75,12 +76,20 @@ mod tests {
anchor_rect: None,
entries: Vec::new(),
swatches: Vec::new(),
+ actions: super::super::types::PanelActions {
+ can_raise: false,
+ can_lower: false,
+ can_edit: false,
+ can_save_preset: false,
+ presets: Vec::new(),
+ },
preview_color: None,
hover: None,
pressed: None,
keyboard_focus: None,
focus_visible: false,
scroll: 0.0,
+ preset_save_mode: false,
multiple_selection: false,
}
}
diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs
index dece88ff1..0511bfbe3 100644
--- a/src/input/state/core/properties/summary.rs
+++ b/src/input/state/core/properties/summary.rs
@@ -81,6 +81,15 @@ pub(super) fn shape_color(shape: &Shape) -> Option {
}
}
+/// The opacity a shape is drawn at: its color's alpha, a marker's own
+/// translucency included.
+pub(super) fn shape_opacity(shape: &Shape) -> Option {
+ match shape {
+ Shape::MarkerStroke { color, .. } => Some(color.a),
+ _ => shape_color(shape).map(|color| color.a),
+ }
+}
+
pub(super) fn shape_thickness(shape: &Shape) -> Option {
match shape {
Shape::Freehand { thick, .. }
@@ -97,11 +106,28 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option {
}
}
-pub(super) fn shape_fill(shape: &Shape) -> Option {
+/// What a closed shape is filled with: `None` for no fill, or the fill's
+/// own color, which falls back to the border's.
+pub(super) fn shape_fill_paint(shape: &Shape) -> Option