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> { match shape { - Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } | Shape::Polygon { fill, .. } => { - Some(*fill) + Shape::Rect { + fill, + fill_color, + color, + .. + } + | Shape::Ellipse { + fill, + fill_color, + color, + .. } + | Shape::Polygon { + fill, + fill_color, + color, + .. + } => Some(fill.then(|| fill_color.unwrap_or(*color))), _ => None, } } @@ -170,6 +196,7 @@ mod tests { w: 10, h: 10, fill, + fill_color: None, color, thick, } @@ -194,7 +221,7 @@ mod tests { wrap_width: None, }); - let summary = summarize_property(&frame, &[text_id], shape_fill, |a, b| a == b); + let summary = summarize_property(&frame, &[text_id], shape_fill_paint, |a, b| a == b); assert!(!summary.applicable); assert!(!summary.editable); diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 6c6b534ad..bf6970e35 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -1,3 +1,4 @@ +use super::metrics::SLIDER_THUMB_RADIUS; use crate::draw::{ArrowStyle, Color}; use crate::util::Rect; @@ -5,6 +6,7 @@ use crate::util::Rect; pub enum SelectionPropertyKind { Color, Thickness, + Opacity, Fill, FontSize, ArrowHead, @@ -15,18 +17,73 @@ pub enum SelectionPropertyKind { SpotlightMagnification, } +/// A slider's range and the grid its values snap to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LevelRange { + pub min: f64, + pub max: f64, + pub step: f64, +} + +impl LevelRange { + /// Where `value` sits along the range, from 0.0 to 1.0. + pub fn fraction(&self, value: f64) -> f64 { + ((value - self.min) / (self.max - self.min)).clamp(0.0, 1.0) + } + + /// The value at `fraction` along the range, on the step grid. + pub fn value_at(&self, fraction: f64) -> f64 { + let raw = self.min + fraction.clamp(0.0, 1.0) * (self.max - self.min); + (self.min + ((raw - self.min) / self.step).round() * self.step).clamp(self.min, self.max) + } + + /// The value with the pointer at `x` on a slider `track`: the thumb's + /// center follows the pointer, and the ends of travel sit a thumb's + /// radius in. + pub fn value_at_x(&self, track: PanelRect, x: f64) -> f64 { + self.value_at((x - track.x - SLIDER_THUMB_RADIUS) / slider_travel(track)) + } + + /// Where the thumb centers on `track` for `value`. + pub fn thumb_x(&self, track: PanelRect, value: f64) -> f64 { + track.x + SLIDER_THUMB_RADIUS + self.fraction(value) * slider_travel(track) + } +} + +fn slider_travel(track: PanelRect) -> f64 { + (track.width - SLIDER_THUMB_RADIUS * 2.0).max(1.0) +} + impl SelectionPropertyKind { + /// The slider range of a property set on a slider. + pub fn level_range(self) -> Option { + match self { + Self::Thickness => Some(LevelRange { + min: crate::domain::MIN_STROKE_THICKNESS, + max: crate::domain::MAX_STROKE_THICKNESS, + step: 1.0, + }), + Self::Opacity => Some(LevelRange { + min: 0.05, + max: 1.0, + step: 0.05, + }), + _ => None, + } + } + /// The kind's value with nothing known about it, for fixtures that only /// care about an entry's text. #[cfg(test)] pub fn unknown_value(self) -> SelectionPropertyValue { match self { Self::Color => SelectionPropertyValue::Color(None), - Self::Fill | Self::TextBackground => SelectionPropertyValue::Toggle(None), + Self::Fill => SelectionPropertyValue::Fill(None), + Self::TextBackground => SelectionPropertyValue::Toggle(None), Self::ArrowHead => SelectionPropertyValue::ArrowHead(None), Self::ArrowStyle => SelectionPropertyValue::ArrowStyle(None), - Self::Thickness - | Self::FontSize + Self::Thickness | Self::Opacity => SelectionPropertyValue::Level(None), + Self::FontSize | Self::ArrowLength | Self::ArrowAngle | Self::SpotlightMagnification => SelectionPropertyValue::Number(None), @@ -43,10 +100,14 @@ impl SelectionPropertyKind { #[derive(Debug, Clone, Copy, PartialEq)] pub enum SelectionPropertyValue { Color(Option), + /// A value set on a slider as well as stepped: thickness and opacity. + Level(Option), Number(Option), /// A pressure stroke stores a width per point, so it has no one number. PressureVaries, Toggle(Option), + /// A closed shape's fill paint: `Some(None)` is no fill. + Fill(Option>), /// Whether the head sits at the end of the arrow (`true`) or its start. ArrowHead(Option), ArrowStyle(Option), @@ -77,12 +138,60 @@ pub enum PropertiesPanelLock { Locked, } +/// A button in the panel's actions area. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelAction { + ToBack, + Backward, + Forward, + ToFront, + Duplicate, + Delete, + /// A preset slot, numbered from 1: applies it, or saves into it while + /// saving is armed. + Preset(usize), + /// Arms saving the selection's style into the next preset slot clicked. + SavePreset, +} + +impl PanelAction { + /// The ordering buttons, bottom of the stack to top. + pub const ORDER: [Self; 4] = [Self::ToBack, Self::Backward, Self::Forward, Self::ToFront]; + /// The buttons under them. + pub const EDIT: [Self; 2] = [Self::Duplicate, Self::Delete]; +} + +/// One tool preset slot, as the panel's preset chips show it. +#[derive(Debug, Clone, PartialEq)] +pub struct PanelPreset { + pub color: Color, + pub name: Option, +} + +/// Which actions can do anything for the current selection. +#[derive(Debug, Clone, PartialEq)] +pub struct PanelActions { + /// Some selected shape has an unselected one above it. + pub can_raise: bool, + /// Some selected shape has an unselected one below it. + pub can_lower: bool, + /// Some selected shape is unlocked, so duplicating, deleting, or applying + /// a preset has something to work on. + pub can_edit: bool, + /// Some editable selected shape is one a tool draws, so its style can be + /// saved as a preset. + pub can_save_preset: bool, + /// The preset slots, in order; `None` for an empty one. + pub presets: Vec>, +} + /// The part of the properties panel under the pointer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PropertiesPanelHit { /// The title, which carries the shape details as a tooltip. Title, Lock, + Action(PanelAction), /// A row away from its controls. Row(usize), Swatch { @@ -91,6 +200,10 @@ pub enum PropertiesPanelHit { }, /// The swatch row's trailing button that opens the full color picker. MoreColors(usize), + /// The fill row's leading "no fill" swatch. + NoFill(usize), + /// A slider's track, which follows the pointer while pressed. + Slider(usize), StepDown(usize), StepUp(usize), Toggle(usize), @@ -108,10 +221,12 @@ impl PropertiesPanelHit { /// The row this part belongs to; `None` for the header. pub fn row(self) -> Option { match self { - Self::Title | Self::Lock => None, + Self::Title | Self::Lock | Self::Action(_) => None, Self::Row(row) | Self::Swatch { row, .. } | Self::MoreColors(row) + | Self::NoFill(row) + | Self::Slider(row) | Self::StepDown(row) | Self::StepUp(row) | Self::Toggle(row) @@ -162,14 +277,21 @@ impl PanelRect { pub enum PropertiesRowControl { Swatches { swatches: Vec, - more: PanelRect, + /// The "no fill" swatch leading a fill row. + none: Option, + /// The "more colors" button trailing a color row. + more: Option, }, Stepper { down: PanelRect, value: PanelRect, up: PanelRect, - /// A short stroke drawn at the current thickness. - preview: Option, + }, + Slider { + /// The track's hit area; the track itself runs along its middle. + track: PanelRect, + /// The readout to the track's right. + value: PanelRect, }, Toggle { switch: PanelRect, @@ -220,10 +342,20 @@ pub struct PropertiesPanelLayout { pub lock: PanelRect, pub divider_y: f64, pub rows_top: f64, + /// Top of the actions area (ordering, Duplicate, Delete, presets), under + /// the rows. + pub actions_top: f64, + /// How many preset slots the preset row shows. + pub preset_slots: usize, /// Top of the keyboard hint strip; `None` when the panel has no rows. pub footer_top: Option, /// Width of the readout between a stepper's − and + buttons. pub stepper_value_width: f64, + /// Where controls beside their label may start, past the widest label. + pub label_column: f64, + /// Width of a slider's readout: room for its widest value, so the + /// track does not shift under the pointer while its value changes. + pub slider_value_width: f64, /// Width of each half of the arrow-head Start/End control. pub head_segment_width: f64, /// Content width of one column of rows. @@ -251,7 +383,8 @@ pub struct ShapePropertiesPanel { pub anchor_rect: Option, pub entries: Vec, pub swatches: Vec, - /// The selection's shared color, for the thickness preview stroke. + pub actions: PanelActions, + /// The selection's shared color, which fills its sliders' tracks. pub preview_color: Option, pub hover: Option, /// The part a pointer press landed on; a release activates it only there. @@ -262,10 +395,35 @@ pub struct ShapePropertiesPanel { pub focus_visible: bool, /// How far the rows are scrolled, when they overflow a short screen. pub scroll: f64, + /// Whether the next preset chip clicked saves into that slot rather than + /// applying it. + pub preset_save_mode: bool, pub multiple_selection: bool, } impl ShapePropertiesPanel { + /// Whether the actions-area button `action` would do anything now. + pub fn action_enabled(&self, action: PanelAction) -> bool { + let actions = &self.actions; + match action { + PanelAction::ToBack | PanelAction::Backward => actions.can_lower, + PanelAction::Forward | PanelAction::ToFront => actions.can_raise, + PanelAction::Duplicate | PanelAction::Delete => actions.can_edit, + PanelAction::SavePreset => actions.can_save_preset, + PanelAction::Preset(slot) => { + if self.preset_save_mode { + actions.can_save_preset + } else { + actions.can_edit + && actions + .presets + .get(slot.wrapping_sub(1)) + .is_some_and(Option::is_some) + } + } + } + } + /// The row under the pointer, if any. pub fn hover_index(&self) -> Option { self.hover.and_then(PropertiesPanelHit::row) diff --git a/src/input/state/core/recognition_chip.rs b/src/input/state/core/recognition_chip.rs index 8df4e803d..2dc8d3ca5 100644 --- a/src/input/state/core/recognition_chip.rs +++ b/src/input/state/core/recognition_chip.rs @@ -190,6 +190,7 @@ mod tests { rx, ry, fill: false, + fill_color: None, color: Color::new(1.0, 0.0, 0.0, 1.0), thick: 3.0, } diff --git a/src/input/state/core/selection/clipboard.rs b/src/input/state/core/selection/clipboard.rs index fdeef1c33..d5ce9449b 100644 --- a/src/input/state/core/selection/clipboard.rs +++ b/src/input/state/core/selection/clipboard.rs @@ -408,6 +408,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color: WHITE, thick: 2.0, } diff --git a/src/input/state/core/selection_actions/reorder.rs b/src/input/state/core/selection_actions/reorder.rs index 03cecdca5..5e37ed772 100644 --- a/src/input/state/core/selection_actions/reorder.rs +++ b/src/input/state/core/selection_actions/reorder.rs @@ -1,6 +1,8 @@ use super::super::base::InputState; +use crate::draw::ShapeId; use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; +use std::collections::HashSet; impl InputState { pub(crate) fn move_selection_to_front_with(&mut self, measurer: &TextMeasurer) -> bool { @@ -11,6 +13,110 @@ impl InputState { self.reorder_selection(measurer, false) } + /// Moves every selected shape one step up, past the nearest unselected + /// shape above it. A run of selected shapes moves as a block, so their + /// order among themselves stays as it was. + pub(crate) fn move_selection_forward_with(&mut self, measurer: &TextMeasurer) -> bool { + self.step_selection(measurer, true) + } + + /// Moves every selected shape one step down; see + /// [`Self::move_selection_forward_with`]. + pub(crate) fn move_selection_backward_with(&mut self, measurer: &TextMeasurer) -> bool { + self.step_selection(measurer, false) + } + + /// Whether some selected shape has an unselected shape above it (or below + /// it, for `forward == false`), so a step that way would move something. + /// + /// That holds exactly when the lowest selected shape sits below the + /// highest unselected one (or, backward, the highest selected above the + /// lowest unselected), so one pass over the stack answers it. The panel + /// asks on every refresh, slider drags included, so it has to stay linear + /// on a board of thousands of shapes. + pub(crate) fn selection_can_step(&self, forward: bool) -> bool { + let selected: HashSet = self.selected_shape_ids().iter().copied().collect(); + if selected.is_empty() { + return false; + } + + let mut lowest_selected = None; + let mut highest_selected = None; + let mut lowest_unselected = None; + let mut highest_unselected = None; + for (index, shape) in self.boards.active_frame().shapes.iter().enumerate() { + let (lowest, highest) = if selected.contains(&shape.id) { + (&mut lowest_selected, &mut highest_selected) + } else { + (&mut lowest_unselected, &mut highest_unselected) + }; + lowest.get_or_insert(index); + *highest = Some(index); + } + + if forward { + matches!((lowest_selected, highest_unselected), (Some(s), Some(u)) if s < u) + } else { + matches!((highest_selected, lowest_unselected), (Some(s), Some(u)) if s > u) + } + } + + fn step_selection(&mut self, measurer: &TextMeasurer, forward: bool) -> bool { + let selected: HashSet = self.selected_shape_ids().iter().copied().collect(); + if selected.is_empty() { + return false; + } + + let len = self.boards.active_frame().shapes.len(); + // Walk against the direction of travel, so a shape that just moved is + // never visited again and a selected block shifts as one. + let indices: Vec = if forward { + (0..len.saturating_sub(1)).rev().collect() + } else { + (1..len).collect() + }; + let mut actions = Vec::new(); + for from in indices { + let to = if forward { from + 1 } else { from - 1 }; + let (moving, neighbour) = { + let shapes = &self.boards.active_frame().shapes; + (shapes[from].id, shapes[to].id) + }; + if !selected.contains(&moving) || selected.contains(&neighbour) { + continue; + } + if self + .boards + .active_frame_mut() + .move_shape(from, to) + .is_some() + { + actions.push(UndoAction::Reorder { + shape_id: moving, + from, + to, + }); + for id in [moving, neighbour] { + if let Some(shape) = self.boards.active_frame().shape(id) { + self.dirty_tracker.mark_shape_with(&shape.shape, measurer); + } + self.invalidate_hit_cache_for_with(measurer, id); + } + } + } + + if actions.is_empty() { + return false; + } + + self.boards.active_frame_mut().push_undo_action( + UndoAction::Compound { actions }, + self.history_limits.undo_stack_limit(), + ); + self.mark_session_dirty(); + true + } + fn reorder_selection(&mut self, measurer: &TextMeasurer, to_front: bool) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index ad5e59564..28a48517d 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -483,6 +483,7 @@ impl DrawingStyle { eraser_mode: Some(self.eraser_mode), marker_opacity: Some(self.marker_opacity), fill_enabled: Some(self.fill_enabled), + fill_color: None, font_size: Some(self.current_font_size), text_background_enabled: Some(self.text_background_enabled), arrow_length: Some(self.arrow_length), diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index 1fcaf1657..3c26e31da 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -114,6 +114,12 @@ impl InputState { pub fn save_preset(&mut self, slot: usize) -> bool { let preset = self.capture_current_preset(); + self.store_preset(slot, preset) + } + + /// Stores `preset` in `slot` and hands it to the backend to persist. + /// Returns false when the slot already holds exactly this preset. + pub(crate) fn store_preset(&mut self, slot: usize, preset: ToolPresetConfig) -> bool { let Some(preset) = self.preset_slots.save(slot, preset) else { return false; }; diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index c06d90031..667905475 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -388,6 +388,7 @@ mod coach_tests { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index e71fa2526..159e577b5 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -97,7 +97,7 @@ impl TourStep { Self::DrawingBasics => "Drawing Basics", Self::ToolbarIntro => "Toolbar Access", Self::CommandPalette => "Command Palette", - Self::ContextMenu => "Context Menu", + Self::ContextMenu => "Menus & Selection", Self::StatusBar => "Boards & Pages", Self::HelpOverlay => "Help & Shortcuts", Self::Presets => "Quick Presets", @@ -194,10 +194,20 @@ impl InputState { ); lines.join("\n") } - TourStep::ContextMenu => "Right-click anywhere for quick actions.\n\ - Access boards, pages, and common commands.\n\ - Shape-specific options when clicking on shapes." - .to_string(), + TourStep::ContextMenu => { + let mut lines = vec![ + "Right-click anywhere for quick actions.".to_string(), + "Access boards, pages, and common commands.".to_string(), + "Shape-specific options when clicking on shapes.".to_string(), + // Alt+click is a fixed mouse gesture, not a binding. + "Alt+click a shape to select it without switching tools.".to_string(), + "Alt+drag moves shapes, or box-selects on empty canvas.".to_string(), + ]; + if let Some(key) = self.shortcut_for_action(Action::ToggleSelectionProperties) { + lines.push(format!("{key} opens the selection's properties.")); + } + lines.join("\n") + } TourStep::StatusBar => { let board = self.ui_visibility.show_status_board_badge && self.boards.show_badge(); let page = self.ui_visibility.show_status_page_badge; @@ -472,6 +482,41 @@ mod tests { ); } + #[test] + fn menus_step_points_out_alt_click_selection() { + use crate::config::{KeybindingsConfig, Shortcut}; + + let mut state = make_test_input_state(); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); + assert!(copy.contains("Alt+drag"), "copy: {copy:?}"); + let properties = state + .shortcut_for_action(Action::ToggleSelectionProperties) + .expect("properties bound"); + assert!(copy.contains(&properties), "copy: {copy:?}"); + + // An unbound properties panel drops its line rather than naming a key. + let mut bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default bindings"); + bindings.insert(Action::ToggleSelectionProperties, Vec::new()); + state.set_action_bindings(bindings); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); + assert!(!copy.contains("properties"), "copy: {copy:?}"); + + let mut bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default bindings"); + bindings.insert( + Action::ToggleSelectionProperties, + vec![Shortcut::parse("Ctrl+Shift+I").expect("binding")], + ); + state.set_action_bindings(bindings); + let copy = state.tour_step_description(TourStep::ContextMenu); + assert!(copy.contains("Ctrl+Shift+I"), "copy: {copy:?}"); + } + #[test] fn starting_tour_exits_focus_mode_before_tour_owns_chrome() { let test_text_measurer = crate::draw::TextMeasurer::default(); diff --git a/src/input/state/core/utility/restore_notice.rs b/src/input/state/core/utility/restore_notice.rs index 62bbcde18..1212824bd 100644 --- a/src/input/state/core/utility/restore_notice.rs +++ b/src/input/state/core/utility/restore_notice.rs @@ -102,6 +102,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color, thick: 3.0, }); diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index 15653e4e5..6729b9931 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -466,6 +466,7 @@ mod tests { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 9fbc73f06..6f4d80b9c 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -92,12 +92,13 @@ pub(crate) fn handle_board_picker_press( pub(crate) fn handle_properties_panel_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, button: MouseButton, points: PointerPoints, ) -> Option { let screen = points.screen(); state - .handle_properties_panel_press(button, screen.x(), screen.y()) + .handle_properties_panel_press_with_measurer(measurer, button, screen.x(), screen.y()) .then_some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } @@ -452,6 +453,7 @@ pub(crate) fn handle_board_picker_motion( pub(crate) fn handle_properties_panel_motion( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> Option { if !state.is_properties_panel_open() { @@ -459,7 +461,7 @@ pub(crate) fn handle_properties_panel_motion( } if state.properties_panel_layout().is_some() { let screen = points.screen(); - state.update_properties_panel_hover_from_pointer(screen.x(), screen.y()); + state.move_properties_panel_pointer_with(measurer, screen.x(), screen.y()); } Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 56ba9dff1..75cc58a10 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -52,6 +52,7 @@ mod tests { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index cb257a2b7..a451192a3 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -49,7 +49,9 @@ pub(crate) fn route_pointer_press( if let Some(outcome) = adapters::handle_board_picker_press(state, event.button(), points) { return outcome; } - if let Some(outcome) = adapters::handle_properties_panel_press(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_properties_panel_press(state, resources.measurer, event.button(), points) + { return outcome; } if event.button() == MouseButton::Left @@ -109,7 +111,7 @@ pub(crate) fn route_pointer_motion( if let Some(outcome) = adapters::handle_board_picker_motion(state, points) { return outcome; } - if let Some(outcome) = adapters::handle_properties_panel_motion(state, points) { + if let Some(outcome) = adapters::handle_properties_panel_motion(state, measurer, points) { return outcome; } if let Some(outcome) = adapters::handle_active_motion(state, measurer, points) { diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 110420876..8400939ff 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -14,9 +14,9 @@ pub(crate) use core::{ }; pub(crate) use core::{InputEffect, InputEffectDrain}; pub(crate) use core::{ - PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, PropertiesPanelLock, - PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, ShapePropertiesPanel, - properties_panel_metrics, + LevelRange, PanelAction, PanelRect, PanelScroll, PropertiesPanelHit, PropertiesPanelLayout, + PropertiesPanelLock, PropertiesPanelSwatch, PropertiesRowControl, PropertiesRowGeometry, + ShapePropertiesPanel, properties_panel_metrics, }; pub(in crate::input::state) use spotlight::SpotlightWheelGesture; pub(crate) use spotlight::{SpotlightFrameRegions, SpotlightWheelClaim, SpotlightWheelOutcome}; diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 7dac8754f..d57d78b19 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -178,11 +178,27 @@ impl InputState { true } + #[cfg(test)] pub(in crate::input::state) fn handle_properties_panel_press( &mut self, button: MouseButton, x: i32, y: i32, + ) -> bool { + self.handle_properties_panel_press_with_measurer( + &crate::draw::TextMeasurer::default(), + button, + x, + y, + ) + } + + pub(in crate::input::state) fn handle_properties_panel_press_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + button: MouseButton, + x: i32, + y: i32, ) -> bool { if !self.is_properties_panel_open() { return false; @@ -193,7 +209,7 @@ impl InputState { } match button { MouseButton::Left => { - if !self.press_properties_panel_at(x, y) { + if !self.press_properties_panel_at_with(measurer, x, y) { self.close_properties_panel(); } } diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs index d13f5c619..41b0a2660 100644 --- a/src/input/state/mouse/press/polygon.rs +++ b/src/input/state/mouse/press/polygon.rs @@ -134,6 +134,7 @@ impl InputState { kind: PolygonKind::Freeform, points, fill, + fill_color: None, color, thick, }; diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index 5bb698063..475ecbf42 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -150,6 +150,7 @@ mod tests { w: 20, h: 20, fill: false, + fill_color: None, color: crate::draw::WHITE, thick: 2.0, }) diff --git a/src/input/state/tests/basics.rs b/src/input/state/tests/basics.rs index 7859bc95d..fe23b5c47 100644 --- a/src/input/state/tests/basics.rs +++ b/src/input/state/tests/basics.rs @@ -88,6 +88,7 @@ fn apply_preset_updates_tool_and_settings() { eraser_mode: Some(EraserMode::Stroke), marker_opacity: Some(0.6), fill_enabled: Some(true), + fill_color: None, font_size: Some(28.0), text_background_enabled: Some(true), arrow_length: Some(25.0), @@ -140,6 +141,7 @@ fn apply_preset_merges_partial_left_drag_tool_bindings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index b2acbf2e1..608bf8dec 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -179,6 +179,7 @@ fn switch_board_cancels_selection_move_on_source_board_before_switching() { w: 30, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs index 274e31c03..66a98a66f 100644 --- a/src/input/state/tests/delete_restore.rs +++ b/src/input/state/tests/delete_restore.rs @@ -272,6 +272,7 @@ fn page_delete_on_last_page_clears_shapes_without_removing_page() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -337,6 +338,7 @@ fn page_content_edit_does_not_stale_pending_page_delete() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index fcb0bb757..cfbdda1dc 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -500,6 +500,7 @@ fn alt_click_selects_filled_polygon_interior() { kind: crate::draw::PolygonKind::Triangle, points: vec![(10, 10), (40, 10), (25, 40)], fill: true, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/erase.rs b/src/input/state/tests/erase.rs index b9deeac62..d3b7be26b 100644 --- a/src/input/state/tests/erase.rs +++ b/src/input/state/tests/erase.rs @@ -169,6 +169,7 @@ fn erase_stroke_hits_various_shapes() { w: 40, h: 20, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -186,6 +187,7 @@ fn erase_stroke_hits_various_shapes() { rx: 20, ry: 10, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -335,6 +337,7 @@ fn spatial_grid_large_tolerance_finds_distant_shapes() { w: 10, h: 10, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index c052c97c6..8fd8fc27f 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -200,6 +200,7 @@ fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/hit_testing.rs b/src/input/state/tests/hit_testing.rs index 3a40bb052..7a6823f1e 100644 --- a/src/input/state/tests/hit_testing.rs +++ b/src/input/state/tests/hit_testing.rs @@ -32,6 +32,7 @@ fn indexed_state_with_two_overlapping_rects() -> InputState { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -85,6 +86,7 @@ fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { w: i32::MIN, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -94,6 +96,7 @@ fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -122,6 +125,7 @@ fn spatial_hit_testing_reuses_resolved_candidate_indices_without_id_rescans() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -131,6 +135,7 @@ fn spatial_hit_testing_reuses_resolved_candidate_indices_without_id_rescans() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }); @@ -201,6 +206,7 @@ fn spatial_hit_testing_rebuilds_after_public_shape_id_replacement() { w: 20, h: 20, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -222,6 +228,7 @@ fn spatial_hit_testing_rebuilds_after_public_shape_id_replacement() { w: 20, h: 20, fill: true, + fill_color: None, color: Color { r: 0.0, g: 0.0, @@ -253,6 +260,7 @@ fn spatial_shape_indices_follow_insert_delete_reorder_undo_and_redo() { w: 20, h: 20, fill: true, + fill_color: None, color, thick: 1.0, }) diff --git a/src/input/state/tests/menus/clipboard.rs b/src/input/state/tests/menus/clipboard.rs index 324025fc3..0483a068b 100644 --- a/src/input/state/tests/menus/clipboard.rs +++ b/src/input/state/tests/menus/clipboard.rs @@ -7,6 +7,7 @@ fn add_rect(state: &mut InputState) -> crate::draw::ShapeId { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index b458e9464..f915c4092 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -52,6 +52,7 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -61,6 +62,7 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -103,6 +105,7 @@ fn shape_menu_includes_reset_canvas_position_on_solid_boards() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -133,6 +136,7 @@ fn select_this_shape_command_focuses_single_shape() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -142,6 +146,7 @@ fn select_this_shape_command_focuses_single_shape() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -174,6 +179,7 @@ fn properties_command_opens_panel() { w: 40, h: 30, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, @@ -412,6 +418,7 @@ fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -421,6 +428,7 @@ fn canvas_menu_uses_clear_unlocked_label_when_canvas_has_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -689,6 +697,7 @@ fn keyboard_shape_menu_anchor_tracks_panned_board_view_offset() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -904,6 +913,7 @@ fn undo_and_redo_rows_follow_history_and_run_it() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); @@ -968,6 +978,7 @@ fn shape_menu_also_ends_with_exit() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); @@ -1038,3 +1049,50 @@ fn open_board_picker_command_closes_context_menu_and_opens_picker() { assert!(!state.is_context_menu_open()); assert!(state.is_board_picker_open()); } + +#[test] +fn shape_menu_orders_by_steps_and_dims_the_way_the_shape_cannot_go() { + let mut state = create_test_input_state(); + let rect = |x| Shape::Rect { + x, + y: 0, + w: 10, + h: 10, + fill: false, + fill_color: None, + color: Color::new(1.0, 0.0, 0.0, 1.0), + thick: 2.0, + }; + let bottom = state.boards.active_frame_mut().add_shape(rect(0)); + let _top = state.boards.active_frame_mut().add_shape(rect(20)); + state.set_selection(vec![bottom]); + state.open_context_menu((0, 0), vec![bottom], ContextMenuKind::Shape, Some(bottom)); + + let entries = state.context_menu_entries(); + let disabled = |label: &str| { + entries + .iter() + .find(|entry| entry.label == label) + .unwrap_or_else(|| panic!("{label} entry")) + .disabled + }; + let labels: Vec<_> = entries.iter().map(|entry| entry.label.as_str()).collect(); + let front = labels + .iter() + .position(|label| *label == "Move to Front") + .unwrap(); + assert_eq!( + &labels[front..front + 4], + [ + "Move to Front", + "Move Forward", + "Move Backward", + "Move to Back" + ] + ); + assert!(!disabled("Move to Front") && !disabled("Move Forward")); + assert!( + disabled("Move Backward") && disabled("Move to Back"), + "the bottom shape cannot go lower" + ); +} diff --git a/src/input/state/tests/menus/history.rs b/src/input/state/tests/menus/history.rs index cb08f9110..1620b0840 100644 --- a/src/input/state/tests/menus/history.rs +++ b/src/input/state/tests/menus/history.rs @@ -11,6 +11,7 @@ fn push_rect_create(state: &mut InputState, x: i32) { w: 10, h: 10, fill: false, + fill_color: None, color, thick, }); @@ -37,6 +38,7 @@ fn undo_all_and_redo_all_process_entire_stack() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -55,6 +57,7 @@ fn undo_all_and_redo_all_process_entire_stack() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/menus/locks.rs b/src/input/state/tests/menus/locks.rs index c96168961..356483467 100644 --- a/src/input/state/tests/menus/locks.rs +++ b/src/input/state/tests/menus/locks.rs @@ -43,6 +43,7 @@ fn shape_menu_disables_delete_when_all_locked() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -52,6 +53,7 @@ fn shape_menu_disables_delete_when_all_locked() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -88,6 +90,7 @@ fn shape_menu_allows_delete_when_mixed_lock_state() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -97,6 +100,7 @@ fn shape_menu_allows_delete_when_mixed_lock_state() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index b74beb9d0..043ffef7c 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -85,6 +85,7 @@ fn presenter_mode_blocks_preset_status_bar_toggle() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index ee66efd85..f2b08db63 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -10,6 +10,7 @@ fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> crate::dr w, h, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) @@ -255,9 +256,10 @@ fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { Shape::Rect { fill, .. } => assert!(*fill), other => panic!("expected rect, got {other:?}"), } + // With no fill color of its own, the fill takes the border's. assert_eq!( - state.properties_panel().expect("panel").entries[fill_index].value, - "On" + state.properties_panel().expect("panel").entries[fill_index].state, + SelectionPropertyValue::Fill(Some(Some(state.style.current_color))) ); } diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index 946284e84..e0b5a7609 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -26,6 +26,7 @@ fn add_rect(state: &mut InputState, color: Color, fill: bool) -> ShapeId { w: 60, h: 40, fill, + fill_color: None, color, thick: 3.0, }) @@ -158,8 +159,16 @@ fn clicking_the_swatch_the_selection_already_has_is_a_quiet_no_op() { assert!(!state.has_active_toast(), "and no \"No changes\" toast"); } +fn arrow_length_of(state: &InputState, id: ShapeId) -> f64 { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { arrow_length, .. } => *arrow_length, + other => panic!("expected arrow, got {other:?}"), + } +} + #[test] -fn stepper_buttons_step_thickness_down_and_up() { +fn stepping_thickness_lands_on_whole_pixels() { + let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); let id = add_rect(&mut state, PALETTE_RED, false); if let Shape::Rect { thick, .. } = @@ -168,51 +177,120 @@ fn stepper_buttons_step_thickness_down_and_up() { *thick = 3.2; } open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); + state.set_properties_panel_focus(Some(row(&state, "Thickness"))); - click(&mut state, PropertiesPanelHit::StepUp(thickness)); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); assert_eq!(rect_of(&state, id).2, 4.0); - click(&mut state, PropertiesPanelHit::StepDown(thickness)); - click(&mut state, PropertiesPanelHit::StepDown(thickness)); - assert_eq!(rect_of(&state, id).2, 2.0); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Left)); + assert_eq!(rect_of(&state, id).2, 3.0); +} + +#[test] +fn stepper_buttons_step_a_number_down_and_up() { + let mut state = create_test_input_state(); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); + open(&mut state, vec![id]); + let length = row(&state, "Arrow length"); + + click(&mut state, PropertiesPanelHit::StepUp(length)); + assert_eq!(arrow_length_of(&state, id), 26.0); + click(&mut state, PropertiesPanelHit::StepDown(length)); + click(&mut state, PropertiesPanelHit::StepDown(length)); + assert_eq!(arrow_length_of(&state, id), 22.0); assert_eq!( - state.properties_panel().unwrap().entries[thickness].value, - "2.0px" + state.properties_panel().unwrap().entries[length].value, + "22px" ); } #[test] fn a_press_that_leaves_its_control_before_the_release_changes_nothing() { let mut state = create_test_input_state(); - let id = add_rect(&mut state, PALETTE_RED, false); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); - let up = point(&state, PropertiesPanelHit::StepUp(thickness)); - let down = point(&state, PropertiesPanelHit::StepDown(thickness)); + let length = row(&state, "Arrow length"); + let up = point(&state, PropertiesPanelHit::StepUp(length)); + let down = point(&state, PropertiesPanelHit::StepDown(length)); click_at(&mut state, up, down); - assert_eq!(rect_of(&state, id).2, 3.0); + assert_eq!(arrow_length_of(&state, id), 24.0); assert!(state.is_properties_panel_open()); } +fn fill_of(state: &InputState, id: ShapeId) -> (bool, Option) { + match &state.boards.active_frame().shape(id).expect("rect").shape { + Shape::Rect { + fill, fill_color, .. + } => (*fill, *fill_color), + other => panic!("expected rect, got {other:?}"), + } +} + +fn swatch_index(state: &InputState, color: Color) -> usize { + state + .properties_panel() + .unwrap() + .swatches + .iter() + .position(|swatch| swatch.color == color) + .expect("swatch") +} + #[test] -fn the_switch_and_the_rest_of_its_row_both_toggle_fill() { +fn a_fill_swatch_fills_with_its_own_color_and_no_fill_keeps_it_for_later() { + let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); let id = add_rect(&mut state, PALETTE_RED, false); open(&mut state, vec![id]); let fill = row(&state, "Fill"); + assert_eq!( + state.properties_panel().unwrap().entries[fill].value, + "None" + ); + let blue = swatch_index(&state, crate::domain::color::PALETTE_BLUE); + + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: blue, + }, + ); + assert_eq!( + fill_of(&state, id), + (true, Some(crate::domain::color::PALETTE_BLUE)) + ); + assert_eq!( + rect_of(&state, id).0, + PALETTE_RED, + "the border keeps its color" + ); + let panel = state.properties_panel().unwrap(); + assert_eq!(panel.entries[fill].value, "Blue"); + assert_eq!(panel.current_swatch(&panel.entries[fill]), Some(blue)); - click(&mut state, PropertiesPanelHit::Toggle(fill)); - assert!(rect_of(&state, id).1); - assert_eq!(state.properties_panel().unwrap().entries[fill].value, "On"); + click(&mut state, PropertiesPanelHit::NoFill(fill)); + assert_eq!( + fill_of(&state, id), + (false, Some(crate::domain::color::PALETTE_BLUE)), + "no fill keeps the color for when the fill comes back" + ); + assert_eq!( + state.properties_panel().unwrap().entries[fill].value, + "None" + ); - click(&mut state, PropertiesPanelHit::Row(fill)); - assert!(!rect_of(&state, id).1); + state.set_properties_panel_focus(Some(fill)); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Return)); + assert_eq!( + fill_of(&state, id), + (true, Some(crate::domain::color::PALETTE_BLUE)) + ); } #[test] -fn a_mixed_fill_selection_turns_on_from_the_switch() { +fn a_mixed_fill_selection_takes_one_fill_from_a_swatch() { let mut state = create_test_input_state(); let filled = add_rect(&mut state, PALETTE_RED, true); let outlined = add_rect(&mut state, PALETTE_RED, false); @@ -220,12 +298,58 @@ fn a_mixed_fill_selection_turns_on_from_the_switch() { let fill = row(&state, "Fill"); assert_eq!( state.properties_panel().unwrap().entries[fill].state, - SelectionPropertyValue::Toggle(None) + SelectionPropertyValue::Fill(None) + ); + let green = swatch_index(&state, PALETTE_GREEN); + + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: green, + }, ); - click(&mut state, PropertiesPanelHit::Toggle(fill)); + assert_eq!(fill_of(&state, filled), (true, Some(PALETTE_GREEN))); + assert_eq!(fill_of(&state, outlined), (true, Some(PALETTE_GREEN))); +} + +#[test] +fn a_fill_shares_the_shapes_opacity() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let half_red = Color { + a: 0.5, + ..PALETTE_RED + }; + let id = add_rect(&mut state, half_red, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + let green = swatch_index(&state, PALETTE_GREEN); + + click( + &mut state, + PropertiesPanelHit::Swatch { + row: fill, + index: green, + }, + ); + assert_eq!( + fill_of(&state, id).1, + Some(Color { + a: 0.5, + ..PALETTE_GREEN + }), + "an opaque swatch fills at the shape's opacity" + ); - assert!(rect_of(&state, filled).1 && rect_of(&state, outlined).1); + state.set_properties_panel_focus(Some(row(&state, "Opacity"))); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); + assert!((rect_of(&state, id).0.a - 0.55).abs() < 1e-9); + assert!( + (fill_of(&state, id).1.unwrap().a - 0.55).abs() < 1e-9, + "the fill follows the border's opacity" + ); } #[test] @@ -297,7 +421,7 @@ fn the_lock_button_locks_the_selection_and_its_rows_go_inert() { let id = add_rect(&mut state, PALETTE_RED, false); open(&mut state, vec![id]); let thickness = row(&state, "Thickness"); - let up = point(&state, PropertiesPanelHit::StepUp(thickness)); + let up = point(&state, PropertiesPanelHit::Slider(thickness)); click(&mut state, PropertiesPanelHit::Lock); @@ -359,18 +483,18 @@ fn wheel_over_a_row_steps_it_and_the_rest_of_the_panel_swallows_it() { fn a_click_focuses_its_row_quietly_while_arrow_keys_show_the_ring() { let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); - let id = add_rect(&mut state, PALETTE_RED, false); + let id = add_arrow(&mut state, ArrowStyle::Standard, true); open(&mut state, vec![id]); - let thickness = row(&state, "Thickness"); + let length = row(&state, "Arrow length"); - click(&mut state, PropertiesPanelHit::StepUp(thickness)); + click(&mut state, PropertiesPanelHit::StepUp(length)); let panel = state.properties_panel().unwrap(); - assert_eq!(panel.keyboard_focus, Some(thickness)); + assert_eq!(panel.keyboard_focus, Some(length)); assert!(!panel.focus_visible, "a click draws no focus ring"); // The arrow keys continue on the clicked row. assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); - assert_eq!(rect_of(&state, id).2, 5.0); + assert_eq!(arrow_length_of(&state, id), 28.0); assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Down)); assert!(state.properties_panel().unwrap().focus_visible); @@ -448,6 +572,79 @@ fn cancelling_the_selection_picker_leaves_the_shapes_alone() { assert!(state.is_properties_panel_open()); } +#[test] +fn more_fill_colors_opens_a_fill_picker_and_ok_fills_with_exactly_that_color() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + let translucent = Color { + r: 0.2, + g: 0.4, + b: 0.6, + a: 0.4, + }; + assert_eq!( + state + .properties_panel() + .unwrap() + .tooltip(PropertiesPanelHit::MoreColors(fill)), + Some("More fill colors…".to_string()) + ); + + click(&mut state, PropertiesPanelHit::MoreColors(fill)); + + assert!(state.is_color_picker_popup_open()); + assert!( + state.is_properties_panel_open(), + "the panel stays open under the picker" + ); + assert_eq!(state.color_picker_popup_title(), "Fill Color"); + assert_eq!( + state.color_picker_popup_current_color(), + Some(PALETTE_RED), + "an unfilled shape would fill with its border color" + ); + + state.color_picker_popup_set_color(translucent); + assert_eq!( + fill_of(&state, id), + (false, None), + "nothing changes before OK" + ); + let depth = undo_depth(&state); + state.apply_color_picker_popup(); + + assert!(!state.is_color_picker_popup_open()); + assert_eq!( + fill_of(&state, id), + (true, Some(translucent)), + "the picker's opacity is the fill's own" + ); + assert_eq!( + rect_of(&state, id).0, + PALETTE_RED, + "the border keeps its color" + ); + assert_eq!(undo_depth(&state), depth + 1); + assert_eq!(state.recent_colors().first(), Some(&translucent)); +} + +#[test] +fn cancelling_the_fill_picker_leaves_the_fill_alone() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let fill = row(&state, "Fill"); + click(&mut state, PropertiesPanelHit::MoreColors(fill)); + + state.color_picker_popup_set_color(PALETTE_GREEN); + state.close_color_picker_popup(true); + + assert_eq!(fill_of(&state, id), (false, None)); + assert!(state.is_properties_panel_open()); +} + #[test] fn ok_on_a_mixed_selection_applies_even_the_color_it_opened_on() { let mut state = create_test_input_state(); @@ -465,7 +662,7 @@ fn ok_on_a_mixed_selection_applies_even_the_color_it_opened_on() { } #[test] -fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { +fn an_opaque_swatch_changes_the_hue_and_keeps_the_shapes_opacity() { let mut state = create_test_input_state(); let faint_red = Color { a: 0.25, @@ -474,14 +671,18 @@ fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { let id = add_rect(&mut state, faint_red, false); open(&mut state, vec![id]); let color_row = row(&state, "Color"); - let red = state - .properties_panel() - .unwrap() - .swatches - .iter() - .position(|swatch| swatch.color == PALETTE_RED) - .expect("red swatch"); + let swatch = |state: &InputState, color: Color| { + state + .properties_panel() + .unwrap() + .swatches + .iter() + .position(|swatch| swatch.color == color) + .expect("swatch") + }; + let depth = undo_depth(&state); + let red = swatch(&state, PALETTE_RED); click( &mut state, PropertiesPanelHit::Swatch { @@ -489,8 +690,25 @@ fn a_same_hue_swatch_still_changes_a_shape_of_another_opacity() { index: red, }, ); + assert_eq!(rect_of(&state, id).0, faint_red, "same hue: nothing to do"); + assert_eq!(undo_depth(&state), depth); - assert_eq!(rect_of(&state, id).0, PALETTE_RED, "now opaque"); + let green = swatch(&state, PALETTE_GREEN); + click( + &mut state, + PropertiesPanelHit::Swatch { + row: color_row, + index: green, + }, + ); + assert_eq!( + rect_of(&state, id).0, + Color { + a: 0.25, + ..PALETTE_GREEN + }, + "the new hue at the shape's own opacity" + ); } #[test] @@ -583,21 +801,17 @@ fn stepping_the_color_tells_opacity_variants_of_one_hue_apart() { seen.push(rect_of(&state, id).0); } + // The translucent swatch sets its opacity; the opaque ones after it + // change the hue and keep that opacity, which the Opacity row owns. + let faint = |color: Color| Color { a: 0.4, ..color }; assert_eq!( seen, - vec![ - Color { - a: 0.4, - ..PALETTE_RED - }, - PALETTE_GREEN, - PALETTE_RED - ] + vec![faint(PALETTE_RED), faint(PALETTE_GREEN), faint(PALETTE_RED)] ); let panel = state.properties_panel().unwrap(); let color_row = row(&state, "Color"); - assert_eq!(panel.entries[color_row].value, "Red"); - assert_eq!(panel.current_swatch(&panel.entries[color_row]), Some(0)); + assert_eq!(panel.entries[color_row].value, "Faint red"); + assert_eq!(panel.current_swatch(&panel.entries[color_row]), Some(1)); } #[test] @@ -634,3 +848,508 @@ fn stepping_a_marker_skips_the_opacity_variant_it_cannot_take() { other => panic!("expected marker, got {other:?}"), } } + +fn stack_order(state: &InputState) -> Vec { + state + .boards + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect() +} + +#[test] +fn order_buttons_move_the_shape_and_dim_at_the_ends_of_the_stack() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let bottom = add_rect(&mut state, PALETTE_RED, false); + let middle = add_rect(&mut state, PALETTE_GREEN, false); + let top = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![bottom]); + let to_back = point(&state, PropertiesPanelHit::Action(PanelAction::ToBack)); + assert_eq!( + state.properties_panel_active_hit_at(to_back.0, to_back.1), + None, + "the bottom shape cannot go lower" + ); + + click(&mut state, PropertiesPanelHit::Action(PanelAction::Forward)); + assert_eq!(stack_order(&state), vec![middle, bottom, top]); + click(&mut state, PropertiesPanelHit::Action(PanelAction::ToFront)); + assert_eq!(stack_order(&state), vec![middle, top, bottom]); + + let panel = state.properties_panel().expect("panel stays open"); + assert!(!panel.action_enabled(PanelAction::ToFront)); + assert!(panel.action_enabled(PanelAction::Backward)); + assert!( + panel + .subtitle + .as_deref() + .unwrap() + .starts_with("Layer 3 of 3"), + "the header follows the move" + ); +} + +#[test] +fn duplicate_keeps_the_panel_on_the_copy_and_delete_closes_it() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let original = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![original]); + + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Duplicate), + ); + assert_eq!(state.boards.active_frame().shapes.len(), 2); + let copy = state.selected_shape_ids().to_vec(); + assert_eq!(copy.len(), 1); + assert_ne!(copy[0], original); + assert!(state.is_properties_panel_open()); + + click(&mut state, PropertiesPanelHit::Action(PanelAction::Delete)); + assert_eq!(stack_order(&state), vec![original]); + lay_out(&mut state); + assert!( + !state.is_properties_panel_open(), + "nothing selected is left to show" + ); +} + +#[test] +fn a_locked_selection_can_be_reordered_but_not_duplicated_or_deleted() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let locked = add_rect(&mut state, PALETTE_RED, false); + let _above = add_rect(&mut state, PALETTE_GREEN, false); + let index = state.boards.active_frame().find_index(locked).unwrap(); + state.boards.active_frame_mut().shapes[index].locked = true; + open(&mut state, vec![locked]); + + let panel = state.properties_panel().unwrap(); + assert!(!panel.action_enabled(PanelAction::Duplicate)); + assert!(!panel.action_enabled(PanelAction::Delete)); + assert!(panel.action_enabled(PanelAction::Forward)); + let delete = point(&state, PropertiesPanelHit::Action(PanelAction::Delete)); + click_at(&mut state, delete, delete); + assert_eq!(state.boards.active_frame().shapes.len(), 2); +} + +#[test] +fn the_opacity_row_steps_by_five_percent_and_a_marker_stays_translucent() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let rect = add_rect(&mut state, PALETTE_RED, false); + let marker = state + .boards + .active_frame_mut() + .add_shape(Shape::MarkerStroke { + points: vec![(100, 100), (200, 120)], + color: Color { + a: 0.85, + ..PALETTE_RED + }, + thick: 12.0, + }); + open(&mut state, vec![rect, marker]); + let opacity = row(&state, "Opacity"); + assert_eq!( + state.properties_panel().unwrap().entries[opacity].value, + "Mixed" + ); + state.set_properties_panel_focus(Some(opacity)); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Right)); + let marker_alpha = + |state: &InputState| match &state.boards.active_frame().shape(marker).unwrap().shape { + Shape::MarkerStroke { color, .. } => color.a, + other => panic!("expected marker, got {other:?}"), + }; + assert_eq!(rect_of(&state, rect).0.a, 1.0, "already at the top"); + assert!( + (marker_alpha(&state) - 0.9).abs() < 1e-9, + "a marker tops out at 90%" + ); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Left)); + assert!((rect_of(&state, rect).0.a - 0.95).abs() < 1e-9); + assert!((marker_alpha(&state) - 0.85).abs() < 1e-9); +} + +#[test] +fn dragging_the_thickness_slider_follows_the_pointer_and_undoes_in_one_step() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + let y = track.center().1 as i32; + let depth = undo_depth(&state); + + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.right() as i32 - 1, + y + )); + assert_eq!( + rect_of(&state, id).2, + 50.0, + "the value jumps to the pointer" + ); + lay_out(&mut state); + state.move_properties_panel_pointer_with(&measurer, track.x as i32 - 40, y); + assert_eq!( + rect_of(&state, id).2, + 1.0, + "a drag past the end stays at it" + ); + assert_eq!(undo_depth(&state), depth, "nothing recorded mid-drag"); + lay_out(&mut state); + state.move_properties_panel_pointer_with(&measurer, track.center().0 as i32, y); + let middle = rect_of(&state, id).2; + assert!(middle > 20.0 && middle < 30.0, "{middle}"); + assert_eq!( + state.properties_panel().unwrap().entries[thickness].value, + format!("{middle:.1}px"), + "the readout follows the drag" + ); + + state.release_properties_panel_at_with(&measurer, track.center().0 as i32, y); + assert_eq!(undo_depth(&state), depth + 1, "the whole drag is one entry"); + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(rect_of(&state, id).2, 3.0); +} + +#[test] +fn closing_the_panel_mid_drag_keeps_the_value_as_one_undo_step() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let opacity = row(&state, "Opacity"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(opacity), + ) + .expect("track"); + let depth = undo_depth(&state); + + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.x as i32, + track.center().1 as i32 + )); + state.close_properties_panel(); + + assert!((rect_of(&state, id).0.a - 0.05).abs() < 1e-9); + assert_eq!(undo_depth(&state), depth + 1); + assert!(!state.is_properties_slider_dragging()); +} + +#[test] +fn the_picker_sets_opacity_exactly() { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let color_row = row(&state, "Color"); + click(&mut state, PropertiesPanelHit::MoreColors(color_row)); + let half_green = Color { + a: 0.5, + ..PALETTE_GREEN + }; + + state.color_picker_popup_set_color(half_green); + state.apply_color_picker_popup(); + + assert_eq!(rect_of(&state, id).0, half_green); +} + +fn add_styled_rect(state: &mut InputState, color: Color, thick: f64, fill: bool) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 100, + y: 100, + w: 60, + h: 40, + fill, + fill_color: None, + color, + thick, + }) +} + +#[test] +fn a_selection_saves_its_style_into_a_preset_slot_and_another_takes_it_on() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let empty_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("an empty slot") + + 1; + let source = add_styled_rect(&mut state, PALETTE_GREEN, 8.0, true); + open(&mut state, vec![source]); + let slot = PropertiesPanelHit::Action(PanelAction::Preset(empty_slot)); + let (x, y) = point(&state, slot); + assert_eq!( + state.properties_panel_active_hit_at(x, y), + None, + "an empty slot has nothing to apply" + ); + + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + assert!(state.properties_panel().unwrap().preset_save_mode); + assert_eq!( + state.properties_panel().unwrap().tooltip(slot), + Some(format!("Save to preset {empty_slot}")) + ); + click(&mut state, slot); + + assert!(!state.properties_panel().unwrap().preset_save_mode); + let saved = state.preset_slots.preset(empty_slot).expect("saved preset"); + assert_eq!(saved.tool, Tool::Rect); + assert_eq!(saved.preview_color(), PALETTE_GREEN); + assert_eq!(saved.size, 8.0); + assert_eq!(saved.fill_enabled, Some(true)); + assert!(matches!( + state.take_pending_preset_action(), + Some(crate::input::state::PresetAction::Save { slot, .. }) if slot == empty_slot + )); + + let tool_before = state.active_tool(); + let target = add_styled_rect(&mut state, PALETTE_RED, 3.0, false); + open(&mut state, vec![target]); + let depth = undo_depth(&state); + click(&mut state, slot); + + assert_eq!(rect_of(&state, target), (PALETTE_GREEN, true, 8.0)); + assert_eq!( + undo_depth(&state), + depth + 1, + "one undo entry for the whole style" + ); + assert_eq!(state.active_tool(), tool_before, "the tool stays as it was"); +} + +#[test] +fn escape_disarms_saving_before_it_closes_the_panel() { + use crate::input::state::PanelAction; + + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Escape)); + assert!(state.is_properties_panel_open()); + assert!(!state.properties_panel().unwrap().preset_save_mode); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Escape)); + assert!(!state.is_properties_panel_open()); +} + +#[test] +fn a_shape_no_tool_draws_cannot_be_saved_as_a_preset() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let note = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 100, + y: 100, + text: "Note".into(), + color: PALETTE_RED, + size: 18.0, + font_descriptor: Default::default(), + background_enabled: false, + wrap_width: None, + }); + open(&mut state, vec![note]); + + assert!( + !state + .properties_panel() + .unwrap() + .action_enabled(PanelAction::SavePreset) + ); +} + +#[test] +fn a_key_pressed_mid_drag_lands_after_the_drag_in_history() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + let (x, y) = (track.right() as i32 - 1, track.center().1 as i32); + + assert!(state.handle_properties_panel_press_with_measurer(&measurer, MouseButton::Left, x, y)); + assert_eq!(rect_of(&state, id).2, 50.0); + assert!(state.handle_properties_panel_key_with_measurer(&measurer, Key::Left)); + assert!( + !state.is_properties_slider_dragging(), + "the key ended the drag" + ); + assert_eq!(rect_of(&state, id).2, 49.0); + state.release_properties_panel_at_with(&measurer, x, y); + assert_eq!( + rect_of(&state, id).2, + 49.0, + "the release does not jump back" + ); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(rect_of(&state, id).2, 50.0, "the key's step undoes first"); + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!( + rect_of(&state, id).2, + 3.0, + "then the drag, back to the start" + ); +} + +#[test] +fn undo_mid_drag_first_lands_the_drag() { + let measurer = TextMeasurer::default(); + let engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + open(&mut state, vec![id]); + let thickness = row(&state, "Thickness"); + let track = state + .properties_panel_layout() + .unwrap() + .hit_rect( + state.properties_panel().unwrap(), + PropertiesPanelHit::Slider(thickness), + ) + .expect("track"); + assert!(state.handle_properties_panel_press_with_measurer( + &measurer, + MouseButton::Left, + track.right() as i32 - 1, + track.center().1 as i32 + )); + + state.handle_action_with_resources(resources, Action::Undo); + + assert!(!state.is_properties_slider_dragging()); + assert_eq!(rect_of(&state, id).2, 3.0, "the drag landed, then undid"); + state.handle_action_with_resources(resources, Action::Redo); + assert_eq!(rect_of(&state, id).2, 50.0); +} + +#[test] +fn a_preset_saved_from_a_separately_filled_shape_brings_its_fill_along() { + use crate::input::state::PanelAction; + + let mut state = create_test_input_state(); + let empty_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("an empty slot") + + 1; + let source = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 100, + y: 100, + w: 60, + h: 40, + fill: true, + fill_color: Some(PALETTE_GREEN), + color: PALETTE_RED, + thick: 3.0, + }); + open(&mut state, vec![source]); + let slot = PropertiesPanelHit::Action(PanelAction::Preset(empty_slot)); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + click(&mut state, slot); + + let target = add_styled_rect(&mut state, crate::domain::color::PALETTE_BLUE, 5.0, true); + open(&mut state, vec![target]); + click(&mut state, slot); + + assert_eq!(rect_of(&state, target).0, PALETTE_RED, "the border"); + assert_eq!( + fill_of(&state, target), + (true, Some(PALETTE_GREEN)), + "the fill" + ); + + // A preset whose fill follows its border resets a separate fill. + let plain_slot = state + .preset_slots + .presets() + .iter() + .position(Option::is_none) + .expect("another empty slot") + + 1; + let plain = add_styled_rect(&mut state, PALETTE_RED, 3.0, true); + open(&mut state, vec![plain]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::SavePreset), + ); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Preset(plain_slot)), + ); + open(&mut state, vec![target]); + click( + &mut state, + PropertiesPanelHit::Action(PanelAction::Preset(plain_slot)), + ); + assert_eq!(fill_of(&state, target), (true, None)); +} diff --git a/src/input/state/tests/selection/actions.rs b/src/input/state/tests/selection/actions.rs index 75d90dafe..7e91193c0 100644 --- a/src/input/state/tests/selection/actions.rs +++ b/src/input/state/tests/selection/actions.rs @@ -16,6 +16,7 @@ fn select_all_action_selects_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -25,6 +26,7 @@ fn select_all_action_selects_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -45,6 +47,7 @@ fn escape_clears_selection_before_exit() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -64,6 +67,7 @@ fn select_tool_drag_selects_shapes_in_rect() { w: 8, h: 8, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -73,6 +77,7 @@ fn select_tool_drag_selects_shapes_in_rect() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/damage.rs b/src/input/state/tests/selection/damage.rs index ba986a6f2..1a186c7ec 100644 --- a/src/input/state/tests/selection/damage.rs +++ b/src/input/state/tests/selection/damage.rs @@ -20,6 +20,7 @@ fn add_filled_rect(state: &mut InputState, x: i32, y: i32) -> crate::draw::Shape w: 40, h: 40, fill: true, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }) diff --git a/src/input/state/tests/selection/deletion.rs b/src/input/state/tests/selection/deletion.rs index 5a60dfb4a..f9c0d736b 100644 --- a/src/input/state/tests/selection/deletion.rs +++ b/src/input/state/tests/selection/deletion.rs @@ -75,6 +75,7 @@ fn clear_all_skips_locked_shapes() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -84,6 +85,7 @@ fn clear_all_skips_locked_shapes() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -113,6 +115,7 @@ fn clear_all_returns_false_when_all_locked() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index 997d7ebe4..dc059fa23 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -21,6 +21,7 @@ fn duplicate_selection_via_action_creates_offset_shape() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -65,6 +66,7 @@ fn copy_paste_selection_centers_shape_at_pointer() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -124,6 +126,7 @@ fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -167,6 +170,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -182,6 +186,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -220,6 +225,7 @@ fn failed_local_clipboard_precedence_clears_when_fingerprint_changes() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -288,6 +294,7 @@ fn failed_local_clipboard_without_failure_fingerprint_supersedes_when_current_is w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -332,6 +339,7 @@ fn failed_local_clipboard_without_current_fingerprint_does_not_fast_path() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -369,6 +377,7 @@ fn published_selection_allows_local_fallback_until_superseded() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -417,6 +426,7 @@ fn fallback_generation_rejects_newer_local_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -460,6 +470,7 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -479,6 +490,7 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -514,6 +526,7 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -535,6 +548,7 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -570,6 +584,7 @@ fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -616,6 +631,7 @@ fn request_generation_supersede_ignores_newer_local_copy() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -635,6 +651,7 @@ fn request_generation_supersede_ignores_newer_local_copy() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -671,6 +688,7 @@ fn failed_local_fast_path_rejects_newer_generation() { w: 100, h: 80, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -703,6 +721,7 @@ fn failed_local_fast_path_rejects_newer_generation() { w: 90, h: 70, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -750,6 +769,7 @@ fn duplicate_selection_skips_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -759,6 +779,7 @@ fn duplicate_selection_skips_locked_shapes() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -787,6 +808,7 @@ fn copy_selection_of_only_locked_shapes_leaves_clipboard_empty() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -815,6 +837,7 @@ fn repeated_paste_selection_uses_current_pointer_anchor() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -846,6 +869,7 @@ fn paste_selection_warns_when_shape_limit_prevents_any_paste() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -870,6 +894,7 @@ fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -879,6 +904,7 @@ fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/state/tests/selection/mod.rs b/src/input/state/tests/selection/mod.rs index cf2a84f6c..4e4b58655 100644 --- a/src/input/state/tests/selection/mod.rs +++ b/src/input/state/tests/selection/mod.rs @@ -5,3 +5,4 @@ mod actions; mod damage; mod deletion; mod duplicate; +mod reorder; diff --git a/src/input/state/tests/selection/reorder.rs b/src/input/state/tests/selection/reorder.rs new file mode 100644 index 000000000..2a718cb5d --- /dev/null +++ b/src/input/state/tests/selection/reorder.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::draw::{ShapeId, TextMeasurer}; + +fn add(state: &mut InputState, x: i32) -> ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y: 0, + w: 10, + h: 10, + fill: false, + fill_color: None, + color: Color::new(1.0, 0.0, 0.0, 1.0), + thick: 2.0, + }) +} + +fn order(state: &InputState) -> Vec { + state + .boards + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect() +} + +fn stack(count: i32) -> (InputState, Vec) { + let mut state = create_test_input_state(); + let ids = (0..count) + .map(|index| add(&mut state, index * 20)) + .collect(); + (state, ids) +} + +#[test] +fn move_to_front_and_back_reach_the_ends_of_the_stack() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(3); + + state.set_selection(vec![ids[0]]); + assert!(state.move_selection_to_front_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[2], ids[0]]); + + state.set_selection(vec![ids[2]]); + assert!(state.move_selection_to_back_with(&measurer)); + assert_eq!(order(&state), vec![ids[2], ids[1], ids[0]]); + assert!( + !state.move_selection_to_back_with(&measurer), + "already at the back" + ); + + // From one below the top, the old reading could not move it at all. + state.set_selection(vec![ids[1]]); + assert!(state.move_selection_to_front_with(&measurer)); + assert_eq!(order(&state), vec![ids[2], ids[0], ids[1]]); +} + +#[test] +fn forward_and_backward_step_one_shape_past_its_neighbour() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(3); + state.set_selection(vec![ids[0]]); + + assert!(state.selection_can_step(true)); + assert!(!state.selection_can_step(false)); + assert!(state.move_selection_forward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[0], ids[2]]); + assert!(state.move_selection_forward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[2], ids[0]]); + assert!(!state.selection_can_step(true)); + assert!( + !state.move_selection_forward_with(&measurer), + "already on top" + ); + + assert!(state.move_selection_backward_with(&measurer)); + assert_eq!(order(&state), vec![ids[1], ids[0], ids[2]]); +} + +#[test] +fn a_selected_block_steps_as_one_and_keeps_its_own_order() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = stack(4); + state.set_selection(vec![ids[0], ids[1]]); + + assert!(state.move_selection_forward_with(&measurer)); + + assert_eq!(order(&state), vec![ids[2], ids[0], ids[1], ids[3]]); +} + +#[test] +fn a_step_is_one_undo_entry_that_undoes_and_redoes() { + let measurer = TextMeasurer::default(); + let resources_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &resources_engine, + }; + let (mut state, ids) = stack(4); + let before = order(&state); + state.set_selection(vec![ids[0], ids[2]]); + + assert!(state.move_selection_forward_with(&measurer)); + let after = order(&state); + assert_eq!(after, vec![ids[1], ids[0], ids[3], ids[2]]); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!(order(&state), before); + state.handle_action_with_resources(resources, Action::Redo); + assert_eq!(order(&state), after); +} + +/// The definition the fast check has to match: some selected shape has an +/// unselected one above (forward) or below (backward) it. +fn can_step_by_definition(state: &InputState, forward: bool) -> bool { + let selected = state.selected_shape_ids(); + let shapes = &state.boards.active_frame().shapes; + let is_selected = |index: usize| selected.contains(&shapes[index].id); + (0..shapes.len()).any(|index| { + is_selected(index) + && if forward { + (index + 1..shapes.len()).any(|above| !is_selected(above)) + } else { + (0..index).any(|below| !is_selected(below)) + } + }) +} + +#[test] +fn step_availability_matches_its_definition_for_every_selection() { + let (mut state, ids) = stack(6); + for mask in 0u32..(1 << ids.len()) { + let selection: Vec = ids + .iter() + .enumerate() + .filter(|(bit, _)| mask & (1 << bit) != 0) + .map(|(_, id)| *id) + .collect(); + state.set_selection(selection); + for forward in [true, false] { + assert_eq!( + state.selection_can_step(forward), + can_step_by_definition(&state, forward), + "mask {mask:06b}, forward {forward}" + ); + } + } +} + +#[test] +fn a_whole_board_selected_has_nowhere_to_step() { + let (mut state, ids) = stack(4_000); + state.set_selection(ids.clone()); + + assert!(!state.selection_can_step(true)); + assert!(!state.selection_can_step(false)); + + state.set_selection(ids[1..].to_vec()); + assert!(!state.selection_can_step(true)); + assert!(state.selection_can_step(false)); +} diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index ba02d769e..9a8a7fac7 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -1934,6 +1934,7 @@ fn apply_full_preset_restores_all_tool_settings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -1984,6 +1985,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2003,6 +2005,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2050,6 +2053,7 @@ fn legacy_preset_changes_only_selected_tool_settings() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: None, text_background_enabled: None, arrow_length: None, @@ -2088,6 +2092,7 @@ fn legacy_step_marker_preset_uses_font_derived_size() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: Some(48.0), text_background_enabled: None, arrow_length: None, @@ -2125,6 +2130,7 @@ fn full_step_marker_preset_uses_captured_profile_size() { eraser_mode: None, marker_opacity: None, fill_enabled: None, + fill_color: None, font_size: Some(48.0), text_background_enabled: None, arrow_length: None, @@ -2387,6 +2393,7 @@ fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: 2.0, }); diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 22f361c23..2b08296f5 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -55,6 +55,7 @@ fn resizing_selection_marks_previous_live_bounds_dirty() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -114,6 +115,7 @@ fn resizing_selection_back_to_start_restores_original_geometry() { w: 20, h: 20, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -184,6 +186,7 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -225,6 +228,7 @@ fn move_selection_to_horizontal_edges_ignores_last_axis() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -267,6 +271,7 @@ fn move_selection_to_vertical_edges_explicit_actions() { w: 20, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -307,6 +312,7 @@ fn nudge_selection_large_uses_large_step() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -339,6 +345,7 @@ fn nudge_selection_clamps_left_and_top_edges() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -370,6 +377,7 @@ fn nudge_selection_clamps_right_and_bottom_edges() { w: 10, h: 10, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 5f344423d..172365fc2 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -243,6 +243,7 @@ impl Tool { w, h, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) @@ -260,6 +261,7 @@ impl Tool { rx, ry, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, } @@ -438,6 +440,7 @@ impl Tool { w, h, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) @@ -455,6 +458,7 @@ impl Tool { rx, ry, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) diff --git a/src/input/tool/drawing/polygon.rs b/src/input/tool/drawing/polygon.rs index 4d403b68b..615f0a499 100644 --- a/src/input/tool/drawing/polygon.rs +++ b/src/input/tool/drawing/polygon.rs @@ -60,6 +60,7 @@ fn finish_polygon( kind: template.kind(snapshot.regular_sides), points, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }, @@ -81,6 +82,7 @@ fn provisional_polygon( kind: template.kind(snapshot.regular_sides), points, fill: snapshot.fill_enabled, + fill_color: None, color: snapshot.color, thick: snapshot.size, }) diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index ffb067d9d..815be5ebf 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -377,6 +377,7 @@ fn fit_rectangle( w: bounds.width.round() as i32, h: bounds.height.round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/ellipse.rs b/src/input/tool/live_shape/ellipse.rs index a3e27c0f0..f0175aee1 100644 --- a/src/input/tool/live_shape/ellipse.rs +++ b/src/input/tool/live_shape/ellipse.rs @@ -85,6 +85,7 @@ pub(super) fn fit_ellipse( rx: rx.round() as i32, ry: ry.round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/grid.rs b/src/input/tool/live_shape/grid.rs index 83331cf3a..eddbc02e1 100644 --- a/src/input/tool/live_shape/grid.rs +++ b/src/input/tool/live_shape/grid.rs @@ -18,6 +18,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { w, h, fill, + fill_color, color, thick, }, @@ -30,6 +31,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { w, h, fill, + fill_color, color, thick, } @@ -42,6 +44,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { rx, ry, fill, + fill_color, color, thick, }, @@ -54,6 +57,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { rx: width / 2, ry: height / 2, fill, + fill_color, color, thick, } @@ -64,6 +68,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { kind, points, fill, + fill_color, color, thick, }, @@ -89,6 +94,7 @@ pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { kind, points, fill, + fill_color, color, thick, } diff --git a/src/input/tool/live_shape/rough_rectangle.rs b/src/input/tool/live_shape/rough_rectangle.rs index 538bbf507..b46ac9bde 100644 --- a/src/input/tool/live_shape/rough_rectangle.rs +++ b/src/input/tool/live_shape/rough_rectangle.rs @@ -74,6 +74,7 @@ pub(super) fn fit_rough_rectangle( w: (right - left).round() as i32, h: (bottom - top).round() as i32, fill: false, + fill_color: None, color, thick, }, diff --git a/src/input/tool/live_shape/triangle.rs b/src/input/tool/live_shape/triangle.rs index 2f1def419..2ff0f8374 100644 --- a/src/input/tool/live_shape/triangle.rs +++ b/src/input/tool/live_shape/triangle.rs @@ -53,6 +53,7 @@ pub(super) fn fit_triangle( .map(|(x, y)| (x.round() as i32, y.round() as i32)) .to_vec(), fill: false, + fill_color: None, color, thick, }, diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 35497bc07..abe8efcc1 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -2231,6 +2231,7 @@ fn save_snapshot_preserves_multiple_pages() { w: 8, h: 8, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 6fa5b1206..7268bf808 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -282,6 +282,7 @@ fn inspect_session_reports_counts_and_flags() { w: 10, h: 10, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/session/tests/history.rs b/src/session/tests/history.rs index 477f488e3..cd5aa8d6a 100644 --- a/src/session/tests/history.rs +++ b/src/session/tests/history.rs @@ -169,6 +169,7 @@ fn clear_all_can_be_undone_after_restore() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 1.0, diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index c43230e62..fca821330 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -144,6 +144,7 @@ fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 1.0, g: 0.0, diff --git a/src/session/tests/roundtrip.rs b/src/session/tests/roundtrip.rs index 1d068e3bb..baa45e6dd 100644 --- a/src/session/tests/roundtrip.rs +++ b/src/session/tests/roundtrip.rs @@ -53,6 +53,7 @@ fn session_roundtrip_preserves_shapes_across_frames() { rx: 4, ry: 8, fill: false, + fill_color: None, color: Color { r: 1.0, g: 1.0, @@ -202,6 +203,7 @@ fn save_snapshot_skips_backup_when_disabled() { w: 5, h: 5, fill: false, + fill_color: None, color: Color { r: 0.0, g: 0.0, diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs index 7db5ed052..9b23a8ddc 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs @@ -9,6 +9,7 @@ fn rect(x: i32, color: Color) -> Shape { w: 130, h: 95, fill: true, + fill_color: None, color, thick: 3.0, } diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 685da1650..678a60c0b 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -303,6 +303,7 @@ mod tests { w: 40, h: 20, fill: true, + fill_color: None, color: Color { r: 1.0, g: 1.0, diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs index 76414a5c8..fbbe241b7 100644 --- a/src/ui/properties_panel.rs +++ b/src/ui/properties_panel.rs @@ -26,11 +26,14 @@ use crate::ui::theme::overlay::{ use crate::ui::theme::{Rgba, set_color, with_alpha}; use crate::ui_text::UiTextEngine; +mod actions; mod controls; +use actions::draw_actions; + use controls::{ - ControlState, draw_arrow_head_segments, draw_arrow_styles, draw_lock, draw_stepper, - draw_swatches, draw_switch, + ControlState, SwatchExtras, draw_arrow_head_segments, draw_arrow_styles, draw_lock, + draw_slider, draw_stepper, draw_swatches, draw_switch, }; /// Wash behind the hovered row: a quieter `BG_HOVER`, so the control under @@ -162,6 +165,8 @@ pub(crate) fn render_properties_panel_with_engine( } } + draw_actions(engine, ctx, panel, layout); + if let Some(footer_top) = layout.footer_top { draw_footer(engine, ctx, layout, footer_top); } @@ -232,10 +237,19 @@ fn draw_row( let state = ControlState { enabled, hover }; match &row.control { - PropertiesRowControl::Swatches { swatches, more } => { + PropertiesRowControl::Swatches { + swatches, + none, + more, + } => { draw_value_right(engine, ctx, row, entry, enabled); + let extras = SwatchExtras { + none: *none, + none_selected: entry.state == SelectionPropertyValue::Fill(Some(None)), + more: *more, + }; let current = panel.current_swatch(entry); - draw_swatches(ctx, &panel.swatches, swatches, *more, current, state); + draw_swatches(ctx, &panel.swatches, swatches, extras, current, state); } PropertiesRowControl::ArrowStyles { buttons } => { draw_value_right(engine, ctx, row, entry, enabled); @@ -245,26 +259,34 @@ fn draw_row( }; draw_arrow_styles(engine, ctx, buttons, current, state); } - PropertiesRowControl::Stepper { - down, - value, - up, - preview, - } => { - let thickness = match entry.state { - SelectionPropertyValue::Number(value) => value, - _ => None, - }; + PropertiesRowControl::Stepper { down, value, up } => { draw_stepper( engine, ctx, (*down, *value, *up), entry.stepper_text(), - preview.zip(thickness), - panel.preview_color, state, ); } + PropertiesRowControl::Slider { track, value } => { + let level = match entry.state { + SelectionPropertyValue::Level(level) => level, + _ => None, + }; + if let Some(range) = entry.kind.level_range() { + draw_slider( + engine, + ctx, + *track, + *value, + &entry.value, + level, + range, + panel.preview_color, + state, + ); + } + } PropertiesRowControl::Toggle { switch } => { let on = match entry.state { SelectionPropertyValue::Toggle(on) => on, diff --git a/src/ui/properties_panel/actions.rs b/src/ui/properties_panel/actions.rs new file mode 100644 index 000000000..89beee42e --- /dev/null +++ b/src/ui/properties_panel/actions.rs @@ -0,0 +1,194 @@ +//! The panel's actions area: the four ordering buttons, and Duplicate and +//! Delete under them. + +use cairo::FontWeight; + +use crate::draw::Color; +use crate::input::state::properties_panel_metrics::{ + ACTION_BUTTON_HEIGHT, ACTION_ROW_GAP, ACTIONS_TOP_GAP, BODY_FONT, VALUE_FONT, text_style, +}; +use crate::input::state::{ + PanelAction, PanelRect, PropertiesPanelHit, PropertiesPanelLayout, ShapePropertiesPanel, +}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::overlay::{ + ACCENT_BRIGHT, ACCENT_PRIMARY, BG_HOVER, BG_HOVER_WASH, DIVIDER_LIGHT, RADIUS_STD, + TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, +}; +use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba, set_color, with_alpha}; +use crate::ui_text::UiTextEngine; + +/// Delete's label, in the destructive red the toolbar's Clear uses. +const DELETE_TEXT: Rgba = rgba(DESTRUCTIVE_RGB, 1.0); +const GLYPH_WIDTH: f64 = 1.6; + +pub(super) fn draw_actions( + engine: &UiTextEngine, + ctx: &cairo::Context, + panel: &ShapePropertiesPanel, + layout: &PropertiesPanelLayout, +) { + set_color(ctx, with_alpha(DIVIDER_LIGHT, 0.4)); + ctx.set_line_width(1.0); + let divider = layout.actions_top + ACTIONS_TOP_GAP / 2.0 - 0.5; + ctx.move_to(layout.content_x(), divider); + ctx.line_to(layout.content_right(), divider); + let _ = ctx.stroke(); + + set_color(ctx, TEXT_PRIMARY); + let label_style = text_style(BODY_FONT, FontWeight::Normal); + let order_top = layout.actions_top + ACTIONS_TOP_GAP; + let preset_top = order_top + (ACTION_BUTTON_HEIGHT + ACTION_ROW_GAP) * 2.0; + for (label, top) in [("Order", order_top), ("Presets", preset_top)] { + engine.draw_baseline( + ctx, + label_style, + label, + layout.content_x(), + top + ACTION_BUTTON_HEIGHT / 2.0 + BODY_FONT * 0.35, + None, + ); + } + + for (action, rect) in layout.action_buttons() { + let enabled = panel.action_enabled(action); + let hovered = enabled && panel.hover == Some(PropertiesPanelHit::Action(action)); + let armed = action == PanelAction::SavePreset && panel.preset_save_mode; + set_color( + ctx, + if armed { + ACCENT_PRIMARY + } else if hovered { + BG_HOVER + } else { + BG_HOVER_WASH + }, + ); + draw_rounded_rect(ctx, rect.x, rect.y, rect.width, rect.height, RADIUS_STD); + let _ = ctx.fill(); + if panel.preset_save_mode && matches!(action, PanelAction::Preset(_)) { + // Saving is armed: the slots are what to click next. + set_color(ctx, with_alpha(ACCENT_BRIGHT, 0.7)); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + rect.x + 0.5, + rect.y + 0.5, + rect.width - 1.0, + rect.height - 1.0, + RADIUS_STD, + ); + let _ = ctx.stroke(); + } + + let ink = match (enabled, action) { + (false, _) => TEXT_DISABLED, + (true, PanelAction::Delete) => DELETE_TEXT, + (true, _) if hovered || armed => TEXT_PRIMARY, + (true, _) => TEXT_SECONDARY, + }; + set_color(ctx, ink); + match action { + PanelAction::Duplicate => draw_label(engine, ctx, rect, "Duplicate"), + PanelAction::Delete => draw_label(engine, ctx, rect, "Delete"), + PanelAction::SavePreset => draw_label(engine, ctx, rect, "Save"), + PanelAction::Preset(slot) => { + let preset = panel.actions.presets.get(slot - 1).and_then(Option::as_ref); + draw_preset_chip(engine, ctx, rect, slot, preset.map(|p| p.color), ink); + } + order => draw_order_glyph(ctx, rect, order), + } + } +} + +/// A preset slot: its number beside a dot of the preset's color, or an +/// empty ring for a slot with nothing saved. +fn draw_preset_chip( + engine: &UiTextEngine, + ctx: &cairo::Context, + rect: PanelRect, + slot: usize, + color: Option, + ink: Rgba, +) { + let style = text_style(VALUE_FONT, FontWeight::Normal); + let number = slot.to_string(); + let number_width = engine + .layout(ctx, style, &number, None) + .ink_extents() + .x_advance(); + let dot = 5.0; + let gap = 6.0; + let (cx, cy) = rect.center(); + let left = cx - (dot * 2.0 + gap + number_width) / 2.0; + + ctx.new_path(); + ctx.arc(left + dot, cy, dot, 0.0, std::f64::consts::TAU); + match color { + Some(color) => { + set_color(ctx, (color.r, color.g, color.b, color.a.max(0.35))); + let _ = ctx.fill(); + } + None => { + set_color(ctx, ink); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + } + } + set_color(ctx, ink); + engine.draw_baseline( + ctx, + style, + &number, + left + dot * 2.0 + gap, + cy + VALUE_FONT * 0.35, + None, + ); +} + +fn draw_label(engine: &UiTextEngine, ctx: &cairo::Context, rect: PanelRect, label: &str) { + let style = text_style(VALUE_FONT, FontWeight::Normal); + let width = engine + .layout(ctx, style, label, None) + .ink_extents() + .x_advance(); + let (cx, cy) = rect.center(); + engine.draw_baseline( + ctx, + style, + label, + cx - width / 2.0, + cy + VALUE_FONT * 0.35, + None, + ); +} + +/// An arrow pointing the way the shape moves; to back and to front add a bar +/// at the end of the stack it goes to. +fn draw_order_glyph(ctx: &cairo::Context, rect: PanelRect, action: PanelAction) { + let (cx, cy) = rect.center(); + let up = matches!(action, PanelAction::Forward | PanelAction::ToFront); + let to_end = matches!(action, PanelAction::ToBack | PanelAction::ToFront); + let (tail, tip) = match (up, to_end) { + (true, true) => (cy + 6.0, cy - 3.0), + (true, false) => (cy + 6.0, cy - 6.0), + (false, true) => (cy - 6.0, cy + 3.0), + (false, false) => (cy - 6.0, cy + 6.0), + }; + let back = if up { tip + 4.0 } else { tip - 4.0 }; + + ctx.set_line_width(GLYPH_WIDTH); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + ctx.move_to(cx, tail); + ctx.line_to(cx, tip); + ctx.move_to(cx - 4.0, back); + ctx.line_to(cx, tip); + ctx.line_to(cx + 4.0, back); + if to_end { + let bar = if up { cy - 6.5 } else { cy + 6.5 }; + ctx.move_to(cx - 6.0, bar); + ctx.line_to(cx + 6.0, bar); + } + let _ = ctx.stroke(); +} diff --git a/src/ui/properties_panel/controls.rs b/src/ui/properties_panel/controls.rs index 3638da60d..dd0ffd0e4 100644 --- a/src/ui/properties_panel/controls.rs +++ b/src/ui/properties_panel/controls.rs @@ -7,10 +7,11 @@ use cairo::FontWeight; use crate::draw::{ArrowStyle, Color}; use crate::input::state::properties_panel_metrics::{ - CAPTION_FONT, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, VALUE_FONT, text_style, + CAPTION_FONT, SEGMENT_ICON_GAP, SEGMENT_ICON_WIDTH, SLIDER_THUMB_RADIUS, SLIDER_TRACK_HEIGHT, + VALUE_FONT, text_style, }; use crate::input::state::{ - PanelRect, PropertiesPanelHit, PropertiesPanelLock, PropertiesPanelSwatch, + LevelRange, PanelRect, PropertiesPanelHit, PropertiesPanelLock, PropertiesPanelSwatch, }; use crate::toolbar_icons::draw_arrow_style_preview; use crate::ui::primitives::{checkerboard_behind, draw_rounded_rect}; @@ -31,6 +32,8 @@ const SWATCH_HAIRLINE: Rgba = (1.0, 1.0, 1.0, 0.18); const QUIET_OUTLINE: Rgba = (1.0, 1.0, 1.0, 0.16); /// A locked selection's padlock: the same amber the toolbar uses for "held". const LOCK_ACTIVE: Rgba = (0.965, 0.827, 0.176, 1.0); +/// The stroke across the "no fill" swatch: the destructive red, as "none". +const NO_FILL_STRIKE: Rgba = crate::ui::theme::rgba(crate::ui::theme::DESTRUCTIVE_RGB, 0.9); /// How much of its color a control keeps while its row is locked. const DISABLED_ALPHA: f64 = 0.35; const GLYPH_WIDTH: f64 = 1.6; @@ -129,11 +132,29 @@ pub(super) fn draw_lock( let _ = ctx.stroke(); } +/// Where a swatch row's extra cells sit, and whether "no fill" is the +/// current choice. +#[derive(Clone, Copy)] +pub(super) struct SwatchExtras { + pub(super) none: Option, + pub(super) none_selected: bool, + pub(super) more: Option, +} + +fn selection_ring(ctx: &cairo::Context, rect: PanelRect, state: ControlState) { + let (cx, cy) = rect.center(); + ctx.new_path(); + ctx.arc(cx, cy, rect.width / 2.0 + 3.5, 0.0, TAU); + set_color(ctx, state.fade(ACCENT_BRIGHT)); + ctx.set_line_width(2.0); + let _ = ctx.stroke(); +} + pub(super) fn draw_swatches( ctx: &cairo::Context, swatches: &[PropertiesPanelSwatch], rects: &[PanelRect], - more: PanelRect, + extras: SwatchExtras, current: Option, state: ControlState, ) { @@ -162,14 +183,44 @@ pub(super) fn draw_swatches( let _ = ctx.stroke(); if current == Some(index) { - ctx.new_path(); - ctx.arc(cx, cy, rect.width / 2.0 + 3.5, 0.0, TAU); - set_color(ctx, state.fade(ACCENT_BRIGHT)); - ctx.set_line_width(2.0); - let _ = ctx.stroke(); + selection_ring(ctx, *rect, state); } } + if let Some(none) = extras.none { + draw_no_fill(ctx, none, extras.none_selected, state); + } + if let Some(more) = extras.more { + draw_more_colors(ctx, more, state); + } +} + +/// "No fill": an empty circle struck through, as a crossed-out swatch. +fn draw_no_fill(ctx: &cairo::Context, rect: PanelRect, selected: bool, state: ControlState) { + let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::NoFill(_))); + let (cx, cy) = rect.center(); + let radius = rect.width / 2.0 - 0.5; + ctx.new_path(); + ctx.arc(cx, cy, radius, 0.0, TAU); + set_color( + ctx, + state.fade(if hovered { + ACCENT_BRIGHT + } else { + QUIET_OUTLINE + }), + ); + ctx.set_line_width(1.5); + let _ = ctx.stroke(); + let reach = radius * std::f64::consts::FRAC_1_SQRT_2; + set_color(ctx, state.fade(NO_FILL_STRIKE)); + round_line(ctx, (cx - reach, cy + reach), (cx + reach, cy - reach)); + if selected { + selection_ring(ctx, rect, state); + } +} + +fn draw_more_colors(ctx: &cairo::Context, more: PanelRect, state: ControlState) { let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::MoreColors(_))); let (cx, cy) = more.center(); let outline = if hovered { @@ -196,22 +247,8 @@ pub(super) fn draw_stepper( ctx: &cairo::Context, (down, value, up): (PanelRect, PanelRect, PanelRect), text: &str, - preview: Option<(PanelRect, f64)>, - preview_color: Option, state: ControlState, ) { - if let Some((rect, thickness)) = preview { - let (_, cy) = rect.center(); - let width = thickness.clamp(1.0, rect.height); - let color = preview_color.map_or(TEXT_SECONDARY, |color| rgba(Color { a: 1.0, ..color })); - set_color(ctx, state.fade(color)); - ctx.set_line_width(width); - ctx.set_line_cap(cairo::LineCap::Round); - ctx.move_to(rect.x + width / 2.0, cy); - ctx.line_to(rect.right() - width / 2.0, cy); - let _ = ctx.stroke(); - } - let well = PanelRect::new(down.x, down.y, up.right() - down.x, down.height); set_color(ctx, BG_HOVER_WASH); draw_rounded_rect(ctx, well.x, well.y, well.width, well.height, RADIUS_STD); @@ -260,6 +297,90 @@ pub(super) fn draw_stepper( layout.show_at_baseline(ctx, cx - extents.x_advance() / 2.0, cy + VALUE_FONT * 0.35); } +/// A slider: a track filled up to the thumb in the selection's color, and +/// the readout to its right. A mixed value draws no fill and no thumb; the +/// readout says "Mixed", and a drag sets every shape alike. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_slider( + engine: &UiTextEngine, + ctx: &cairo::Context, + track: PanelRect, + readout: PanelRect, + text: &str, + value: Option, + range: LevelRange, + fill: Option, + state: ControlState, +) { + let (_, cy) = track.center(); + let left = track.x + SLIDER_THUMB_RADIUS; + let right = track.right() - SLIDER_THUMB_RADIUS; + let half = SLIDER_TRACK_HEIGHT / 2.0; + set_color(ctx, state.fade(SWITCH_TRACK_OFF)); + draw_rounded_rect( + ctx, + left - half, + cy - half, + right - left + SLIDER_TRACK_HEIGHT, + SLIDER_TRACK_HEIGHT, + half, + ); + let _ = ctx.fill(); + + if let Some(value) = value { + let thumb = range.thumb_x(track, value); + let fill = fill.map_or(ACCENT_PRIMARY, |color| rgba(Color { a: 1.0, ..color })); + set_color(ctx, state.fade(fill)); + draw_rounded_rect( + ctx, + left - half, + cy - half, + thumb - left + SLIDER_TRACK_HEIGHT, + SLIDER_TRACK_HEIGHT, + half, + ); + let _ = ctx.fill(); + + let hovered = state.hovers(|hit| matches!(hit, PropertiesPanelHit::Slider(_))); + ctx.new_path(); + ctx.arc(thumb, cy, SLIDER_THUMB_RADIUS, 0.0, TAU); + set_color(ctx, state.fade(TEXT_WHITE)); + let _ = ctx.fill_preserve(); + set_color( + ctx, + state.fade(if hovered { + ACCENT_BRIGHT + } else { + with_alpha(ACCENT_PRIMARY, 0.6) + }), + ); + ctx.set_line_width(if hovered { 3.0 } else { 2.0 }); + let _ = ctx.stroke(); + } + + let style = text_style(VALUE_FONT, FontWeight::Normal); + let width = engine + .layout(ctx, style, text, None) + .ink_extents() + .x_advance(); + set_color( + ctx, + if state.enabled { + TEXT_PRIMARY + } else { + TEXT_DISABLED + }, + ); + engine.draw_baseline( + ctx, + style, + text, + readout.right() - width, + readout.center().1 + VALUE_FONT * 0.35, + None, + ); +} + pub(super) fn draw_switch( ctx: &cairo::Context, rect: PanelRect, diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index cdf8dd47d..a97a0c27f 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -80,6 +80,7 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { w: 30, h: 40, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 236e76312..b80d35bc0 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -206,6 +206,7 @@ pub(crate) const fn selection_kind_slug(kind: SelectionPropertyKind) -> &'static match kind { SelectionPropertyKind::Color => "color", SelectionPropertyKind::Thickness => "thickness", + SelectionPropertyKind::Opacity => "opacity", SelectionPropertyKind::Fill => "fill", SelectionPropertyKind::FontSize => "font-size", SelectionPropertyKind::ArrowHead => "arrow-head", @@ -227,6 +228,7 @@ pub(crate) const fn selection_control_for_kind(kind: SelectionPropertyKind) -> S | SelectionPropertyKind::ArrowStyle | SelectionPropertyKind::TextBackground => StylePillControl::SelectionCycle(kind), SelectionPropertyKind::Thickness + | SelectionPropertyKind::Opacity | SelectionPropertyKind::FontSize | SelectionPropertyKind::ArrowLength | SelectionPropertyKind::ArrowAngle @@ -267,9 +269,12 @@ impl StylePillSpec { } if state == StylePillState::Selection { + // Opacity stays in the properties panel: the strip is short of + // width, and a selection's opacity is not a mid-demo adjustment. let mut controls: Vec<_> = snapshot .selection_properties .iter() + .filter(|entry| entry.kind != SelectionPropertyKind::Opacity) .map(|entry| selection_control_for_kind(entry.kind)) .collect(); if snapshot.selection_has_text && !plan.drop_style_extras { diff --git a/src/ui/toolbar/model/style_pill/tests/selection.rs b/src/ui/toolbar/model/style_pill/tests/selection.rs index 73916d962..51ef30138 100644 --- a/src/ui/toolbar/model/style_pill/tests/selection.rs +++ b/src/ui/toolbar/model/style_pill/tests/selection.rs @@ -258,3 +258,22 @@ fn allocation_free_queries_match_the_materialized_spec() { ); } } + +#[test] +fn opacity_stays_in_the_properties_panel_and_off_the_pill() { + let mut snapshot = selection_snapshot(); + snapshot.selection_properties.push(selection_entry( + "Opacity", + "100%", + SelectionPropertyKind::Opacity, + false, + )); + + let spec = StylePillSpec::build(&snapshot, &TopStripPlan::unconstrained()); + + assert!(spec.controls().iter().all(|control| !matches!( + control, + StylePillControl::SelectionStepper(SelectionPropertyKind::Opacity) + | StylePillControl::SelectionCycle(SelectionPropertyKind::Opacity) + ))); +} diff --git a/tests/ui.rs b/tests/ui.rs index 84b864107..321d9863b 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -178,6 +178,7 @@ fn render_shape_ellipse_does_not_connect_to_existing_current_path() { rx: 20, ry: 10, fill: false, + fill_color: None, color: magenta, thick: 6.0, },