Merge pull request #240 from devmobasa/feature/issue-219-expanded-geometry-tools

Add expanded geometry tools (triangle, polygon, and freeform shape tools)
This commit is contained in:
devmobasa
2026-06-01 16:49:27 +02:00
committed by GitHub
115 changed files with 2909 additions and 280 deletions
+5 -2
View File
@@ -201,7 +201,7 @@ For distro-specific package details, see [Installation](#installation). For keyb
### Drawing & Editing
- Freehand pen, highlighter, eraser (circle/rect)
- Shapes: lines, rectangles, ellipses (with fill toggle)
- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
- Arrows with optional auto-numbered labels
- Step markers for walkthroughs
- Multiline text & sticky notes with smoothing
@@ -611,12 +611,14 @@ Press <kbd>F1</kbd> for the complete in-app cheat sheet.
| Rectangle | <kbd>Ctrl</kbd> + drag |
| Ellipse/Circle | <kbd>Tab</kbd> + drag |
| Arrow | <kbd>Ctrl+Shift</kbd> + drag |
| Triangle / parallelogram / rhombus / regular polygon | Toolbar Polygons picker (bindable) |
| Freeform polygon | Toolbar Polygons picker, then click vertices; <kbd>Enter</kbd> or double-click to finish |
| Step marker tool | Toolbar (bindable) |
| Highlight brush | <kbd>Ctrl+Alt+H</kbd> |
| Text mode | <kbd>T</kbd>, <kbd>Click</kbd> to position, type, <kbd>Enter</kbd> to finish |
| Sticky note | <kbd>N</kbd>, <kbd>Click</kbd> to place, type, <kbd>Enter</kbd> to finish |
Drag modifier mappings are configurable in `config.toml` via `[drawing]` (`drag_tool`, `shift_drag_tool`, `ctrl_drag_tool`, `ctrl_shift_drag_tool`, `tab_drag_tool`) or in the configurator Drawing tab. For per-button workflows, use `[drawing.drag_tools.left]`, `[drawing.drag_tools.right]`, and `[drawing.drag_tools.middle]`; each binding can set a tool and optional color.
The polygon tools are available from the toolbar picker; their default keybindings are intentionally empty. Drag modifier mappings are configurable in `config.toml` via `[drawing]` (`drag_tool`, `shift_drag_tool`, `ctrl_drag_tool`, `ctrl_shift_drag_tool`, `tab_drag_tool`) or in the configurator Drawing tab. For per-button workflows, use `[drawing.drag_tools.left]`, `[drawing.drag_tools.right]`, and `[drawing.drag_tools.middle]`; each binding can set a tool and optional color. Freeform polygon is selectable but not drag-bindable.
</details>
@@ -757,6 +759,7 @@ wayscriber-configurator # or press F11
[drawing]
default_color = "red"
default_thickness = 3.0
polygon_sides = 5
[drawing.drag_tools.right]
drag_tool = "pen"
+16 -4
View File
@@ -86,6 +86,11 @@ toggle_eraser_mode = ["Ctrl+Shift+E"]
select_line_tool = []
select_rect_tool = []
select_ellipse_tool = []
select_triangle_tool = []
select_parallelogram_tool = []
select_rhombus_tool = []
select_regular_polygon_tool = []
select_freeform_polygon_tool = []
select_arrow_tool = []
select_blur_tool = []
select_highlight_tool = []
@@ -145,7 +150,7 @@ render_profile_next = []
render_profile_previous = []
render_profile_off = []
# Toggle fill for rectangle/ellipse
# Toggle fill for fill-capable shapes
toggle_fill = []
# Optional keyboard binding to toggle radial menu at cursor
@@ -725,9 +730,12 @@ default_eraser_mode = "brush"
# Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha.
marker_opacity = 0.32
# Default fill state for shapes (rect/ellipse)
# Default fill state for fill-capable shapes
default_fill_enabled = false
# Default regular polygon side count (3 - 12)
polygon_sides = 5
# Default font size for text mode (8.0 - 72.0)
default_font_size = 32.0
@@ -741,7 +749,9 @@ hit_test_linear_threshold = 400
undo_stack_limit = 100
# Drag gesture tool mapping (defaults match existing behavior)
# Allowed values use kebab-case tool names, e.g. "pen", "arrow", "eraser"
# Allowed values use kebab-case drag-bindable tool names, e.g. "pen",
# "arrow", "eraser", "triangle", "regular-polygon".
# Freeform polygon is selectable from the toolbar picker but is not drag-bindable.
drag_tool = "pen"
shift_drag_tool = "line"
ctrl_drag_tool = "rect"
@@ -819,7 +829,9 @@ slot_count = 5
[presets.slot_1]
# Optional label shown in UI tooltips
name = "Red pen"
# Tool: "pen", "line", "rect", "ellipse", "arrow", "marker", "highlight", "eraser", "select"
# Tool: "pen", "line", "rect", "ellipse", "triangle", "parallelogram",
# "rhombus", "regular-polygon", "freeform-polygon", "arrow", "marker",
# "highlight", "eraser", "select"
tool = "pen"
# Color: name or RGB array
color = "red"
+8
View File
@@ -47,6 +47,14 @@ impl ConfiguratorApp {
TextField::DrawingFontSize,
Some("Range: 8-72 pt"),
validate_f64_range(&self.draft.drawing_default_font_size, 8.0, 72.0),
),
labeled_input_with_feedback(
"Polygon sides",
&self.draft.drawing_polygon_sides,
&self.defaults.drawing_polygon_sides,
TextField::DrawingPolygonSides,
Some("Range: 3-12"),
validate_usize_range(&self.draft.drawing_polygon_sides, 3, 12),
)
]
.spacing(12),
@@ -32,6 +32,7 @@ impl ConfigDraft {
config.drawing.default_eraser_mode,
),
drawing_default_font_size: format_float(config.drawing.default_font_size),
drawing_polygon_sides: config.drawing.polygon_sides.to_string(),
drawing_marker_opacity: format_float(config.drawing.marker_opacity),
drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance),
drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(),
@@ -41,13 +42,19 @@ impl ConfigDraft {
drawing_font_style: style_value,
drawing_text_background_enabled: config.drawing.text_background_enabled,
drawing_default_fill_enabled: config.drawing.default_fill_enabled,
drawing_drag_tool: ToolOption::from_tool(config.drawing.drag_tool),
drawing_shift_drag_tool: ToolOption::from_tool(config.drawing.shift_drag_tool),
drawing_ctrl_drag_tool: ToolOption::from_tool(config.drawing.ctrl_drag_tool),
drawing_ctrl_shift_drag_tool: ToolOption::from_tool(
drawing_drag_tool: ToolOption::from_drag_bindable_tool(config.drawing.drag_tool),
drawing_shift_drag_tool: ToolOption::from_drag_bindable_tool(
config.drawing.shift_drag_tool,
),
drawing_ctrl_drag_tool: ToolOption::from_drag_bindable_tool(
config.drawing.ctrl_drag_tool,
),
drawing_ctrl_shift_drag_tool: ToolOption::from_drag_bindable_tool(
config.drawing.ctrl_shift_drag_tool,
),
drawing_tab_drag_tool: ToolOption::from_tool(config.drawing.tab_drag_tool),
drawing_tab_drag_tool: ToolOption::from_drag_bindable_tool(
config.drawing.tab_drag_tool,
),
drawing_drag_tools,
drawing_font_style_option: style_option,
drawing_font_weight_option: weight_option,
@@ -23,6 +23,7 @@ pub struct ConfigDraft {
pub drawing_default_eraser_size: String,
pub drawing_default_eraser_mode: EraserModeOption,
pub drawing_default_font_size: String,
pub drawing_polygon_sides: String,
pub drawing_marker_opacity: String,
pub drawing_hit_test_tolerance: String,
pub drawing_hit_test_linear_threshold: String,
+14
View File
@@ -29,6 +29,20 @@ pub(super) fn parse_usize_field<F>(
}
}
pub(super) fn parse_u8_field<F>(
value: &str,
field: &'static str,
errors: &mut Vec<FormError>,
apply: F,
) where
F: FnOnce(u8),
{
match value.trim().parse::<u8>() {
Ok(parsed) => apply(parsed),
Err(err) => errors.push(FormError::new(field, err.to_string())),
}
}
pub(super) fn parse_optional_usize_field<F>(
value: &str,
field: &'static str,
@@ -27,6 +27,7 @@ pub struct PresetSlotDraft {
pub arrow_length: String,
pub arrow_angle: String,
pub arrow_head_at_end: OverrideOption,
pub polygon_sides: Option<u8>,
pub show_status_bar: OverrideOption,
pub drag_tools: Option<MouseDragToolsConfig>,
pub tool_settings: Option<PresetToolStatesConfig>,
@@ -52,6 +53,7 @@ impl PresetSlotDraft {
arrow_length: preset.arrow_length.map(format_float).unwrap_or_default(),
arrow_angle: preset.arrow_angle.map(format_float).unwrap_or_default(),
arrow_head_at_end: OverrideOption::from_option(preset.arrow_head_at_end),
polygon_sides: preset.polygon_sides,
show_status_bar: OverrideOption::from_option(preset.show_status_bar),
drag_tools: preset.drag_tools.clone(),
tool_settings: preset.tool_settings.clone(),
@@ -80,6 +82,7 @@ impl PresetSlotDraft {
arrow_length: String::new(),
arrow_angle: String::new(),
arrow_head_at_end: OverrideOption::Default,
polygon_sides: None,
show_status_bar: OverrideOption::Default,
drag_tools: None,
tool_settings: None,
@@ -176,6 +179,7 @@ impl PresetSlotDraft {
arrow_length,
arrow_angle,
arrow_head_at_end: self.arrow_head_at_end.to_option(),
polygon_sides: self.polygon_sides,
show_status_bar: self.show_status_bar.to_option(),
drag_tools: self.drag_tools.clone(),
})
@@ -238,6 +238,7 @@ impl ConfigDraft {
TextField::DrawingThickness => self.drawing_default_thickness = value,
TextField::DrawingEraserSize => self.drawing_default_eraser_size = value,
TextField::DrawingFontSize => self.drawing_default_font_size = value,
TextField::DrawingPolygonSides => self.drawing_polygon_sides = value,
TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value,
TextField::DrawingFontFamily => self.drawing_font_family = value,
TextField::DrawingFontWeight => {
+22 -5
View File
@@ -369,6 +369,7 @@ fn config_draft_round_trips_presets_and_history() {
arrow_length: Some(20.0),
arrow_angle: Some(30.0),
arrow_head_at_end: Some(true),
polygon_sides: Some(7),
show_status_bar: Some(false),
drag_tools: None,
};
@@ -437,6 +438,7 @@ fn preset_tool_change_loads_selected_tool_profile_values() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
}),
@@ -494,6 +496,7 @@ fn preset_visible_edits_update_selected_tool_profile_only() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
}),
@@ -525,11 +528,11 @@ fn preset_visible_edits_update_selected_tool_profile_only() {
#[test]
fn config_draft_round_trips_drag_tool_mapping() {
let mut config = Config::default();
config.drawing.drag_tool = Tool::Arrow;
config.drawing.shift_drag_tool = Tool::Eraser;
config.drawing.ctrl_drag_tool = Tool::Pen;
config.drawing.ctrl_shift_drag_tool = Tool::Rect;
config.drawing.tab_drag_tool = Tool::Ellipse;
config.drawing.drag_tool = wayscriber::input::DragBindableTool::Arrow;
config.drawing.shift_drag_tool = wayscriber::input::DragBindableTool::Eraser;
config.drawing.ctrl_drag_tool = wayscriber::input::DragBindableTool::Pen;
config.drawing.ctrl_shift_drag_tool = wayscriber::input::DragBindableTool::Rect;
config.drawing.tab_drag_tool = wayscriber::input::DragBindableTool::Ellipse;
let mut drag_tools = config.drawing.effective_drag_tools();
drag_tools.right.drag_tool = DragTool::Pen;
drag_tools.right.drag_color = Some(ColorSpec::Name("blue".to_string()));
@@ -566,6 +569,20 @@ fn config_draft_round_trips_drag_tool_mapping() {
assert_eq!(round_trip.drawing.drag_tools, Some(drag_tools));
}
#[test]
fn config_draft_round_trips_polygon_sides() {
let mut config = Config::default();
config.drawing.polygon_sides = 8;
let draft = ConfigDraft::from_config(&config);
assert_eq!(draft.drawing_polygon_sides, "8");
let round_trip = draft
.to_config(&config)
.expect("expected config to round trip");
assert_eq!(round_trip.drawing.polygon_sides, 8);
}
#[test]
fn config_draft_round_trips_xdg_focus_loss_behavior() {
let mut config = Config::default();
@@ -1,8 +1,8 @@
use super::super::draft::ConfigDraft;
use super::super::parse::{parse_field, parse_usize_field};
use super::super::parse::{parse_field, parse_u8_field, parse_usize_field};
use crate::models::error::FormError;
use wayscriber::config::Config;
use wayscriber::input::Tool;
use wayscriber::input::{DragBindableTool, DragTool};
impl ConfigDraft {
pub(super) fn apply_drawing(&self, config: &mut Config, errors: &mut Vec<FormError>) {
@@ -29,6 +29,12 @@ impl ConfigDraft {
errors,
|value| config.drawing.default_font_size = value,
);
parse_u8_field(
&self.drawing_polygon_sides,
"drawing.polygon_sides",
errors,
|value| config.drawing.polygon_sides = value,
);
parse_field(
&self.drawing_marker_opacity,
"drawing.marker_opacity",
@@ -40,17 +46,26 @@ impl ConfigDraft {
config.drawing.font_style = self.drawing_font_style.clone();
config.drawing.text_background_enabled = self.drawing_text_background_enabled;
config.drawing.default_fill_enabled = self.drawing_default_fill_enabled;
config.drawing.drag_tool = legacy_tool(self.drawing_drag_tools.left.drag_tool, Tool::Pen);
config.drawing.shift_drag_tool =
legacy_tool(self.drawing_drag_tools.left.shift_drag_tool, Tool::Line);
config.drawing.ctrl_drag_tool =
legacy_tool(self.drawing_drag_tools.left.ctrl_drag_tool, Tool::Rect);
config.drawing.drag_tool = legacy_tool(
self.drawing_drag_tools.left.drag_tool,
DragBindableTool::Pen,
);
config.drawing.shift_drag_tool = legacy_tool(
self.drawing_drag_tools.left.shift_drag_tool,
DragBindableTool::Line,
);
config.drawing.ctrl_drag_tool = legacy_tool(
self.drawing_drag_tools.left.ctrl_drag_tool,
DragBindableTool::Rect,
);
config.drawing.ctrl_shift_drag_tool = legacy_tool(
self.drawing_drag_tools.left.ctrl_shift_drag_tool,
Tool::Arrow,
DragBindableTool::Arrow,
);
config.drawing.tab_drag_tool = legacy_tool(
self.drawing_drag_tools.left.tab_drag_tool,
DragBindableTool::Ellipse,
);
config.drawing.tab_drag_tool =
legacy_tool(self.drawing_drag_tools.left.tab_drag_tool, Tool::Ellipse);
config.drawing.drag_tools = Some(self.drawing_drag_tools.clone());
parse_field(
&self.drawing_hit_test_tolerance,
@@ -81,6 +96,6 @@ impl ConfigDraft {
}
}
fn legacy_tool(tool: wayscriber::input::DragTool, fallback: Tool) -> Tool {
tool.as_tool().unwrap_or(fallback)
fn legacy_tool(tool: DragTool, fallback: DragBindableTool) -> DragBindableTool {
DragBindableTool::from_drag_tool(tool).unwrap_or(fallback)
}
+12
View File
@@ -82,8 +82,20 @@ fn pdf_export_options_round_trip() {
fn drag_tool_options_match_button_capabilities() {
let left = DragToolOption::list_for_button(DragMouseButton::Left);
assert!(!left.contains(&DragToolOption::Default));
assert!(left.contains(&DragToolOption::RegularPolygon));
let right = DragToolOption::list_for_button(DragMouseButton::Right);
assert!(right.contains(&DragToolOption::Default));
assert_eq!(right, DragToolOption::list());
}
#[test]
fn tool_options_include_freeform_but_drag_options_do_not() {
assert!(ToolOption::list().contains(&ToolOption::FreeformPolygon));
assert!(ToolOption::list().contains(&ToolOption::RegularPolygon));
assert!(
!DragToolOption::list()
.iter()
.any(|option| option.to_tool_option() == Some(ToolOption::FreeformPolygon))
);
}
@@ -81,6 +81,7 @@ pub enum TextField {
DrawingThickness,
DrawingEraserSize,
DrawingFontSize,
DrawingPolygonSides,
DrawingMarkerOpacity,
DrawingFontFamily,
DrawingFontWeight,
+54 -1
View File
@@ -1,5 +1,5 @@
use wayscriber::config::ColorSpec;
use wayscriber::input::{DragTool, Tool};
use wayscriber::input::{DragBindableTool, DragTool, Tool};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragMouseButton {
@@ -46,6 +46,11 @@ pub enum ToolOption {
Line,
Rect,
Ellipse,
Triangle,
Parallelogram,
Rhombus,
RegularPolygon,
FreeformPolygon,
Arrow,
Blur,
Marker,
@@ -62,6 +67,11 @@ impl ToolOption {
Self::Line,
Self::Rect,
Self::Ellipse,
Self::Triangle,
Self::Parallelogram,
Self::Rhombus,
Self::RegularPolygon,
Self::FreeformPolygon,
Self::Arrow,
Self::Blur,
Self::Marker,
@@ -78,6 +88,11 @@ impl ToolOption {
Self::Line => "Line",
Self::Rect => "Rectangle",
Self::Ellipse => "Ellipse",
Self::Triangle => "Triangle",
Self::Parallelogram => "Parallelogram",
Self::Rhombus => "Rhombus",
Self::RegularPolygon => "Regular polygon",
Self::FreeformPolygon => "Freeform polygon",
Self::Arrow => "Arrow",
Self::Blur => "Blur",
Self::Marker => "Marker",
@@ -94,6 +109,11 @@ impl ToolOption {
Self::Line => Tool::Line,
Self::Rect => Tool::Rect,
Self::Ellipse => Tool::Ellipse,
Self::Triangle => Tool::Triangle,
Self::Parallelogram => Tool::Parallelogram,
Self::Rhombus => Tool::Rhombus,
Self::RegularPolygon => Tool::RegularPolygon,
Self::FreeformPolygon => Tool::FreeformPolygon,
Self::Arrow => Tool::Arrow,
Self::Blur => Tool::Blur,
Self::Marker => Tool::Marker,
@@ -110,6 +130,11 @@ impl ToolOption {
Tool::Line => Self::Line,
Tool::Rect => Self::Rect,
Tool::Ellipse => Self::Ellipse,
Tool::Triangle => Self::Triangle,
Tool::Parallelogram => Self::Parallelogram,
Tool::Rhombus => Self::Rhombus,
Tool::RegularPolygon => Self::RegularPolygon,
Tool::FreeformPolygon => Self::FreeformPolygon,
Tool::Arrow => Self::Arrow,
Tool::Blur => Self::Blur,
Tool::Marker => Self::Marker,
@@ -118,6 +143,10 @@ impl ToolOption {
Tool::Eraser => Self::Eraser,
}
}
pub fn from_drag_bindable_tool(tool: DragBindableTool) -> Self {
Self::from_tool(tool.to_tool())
}
}
impl std::fmt::Display for ToolOption {
@@ -134,6 +163,10 @@ pub enum DragToolOption {
Line,
Rect,
Ellipse,
Triangle,
Parallelogram,
Rhombus,
RegularPolygon,
Arrow,
Blur,
Marker,
@@ -151,6 +184,10 @@ impl DragToolOption {
Self::Line,
Self::Rect,
Self::Ellipse,
Self::Triangle,
Self::Parallelogram,
Self::Rhombus,
Self::RegularPolygon,
Self::Arrow,
Self::Blur,
Self::Marker,
@@ -176,6 +213,10 @@ impl DragToolOption {
Self::Line => "Line",
Self::Rect => "Rectangle",
Self::Ellipse => "Ellipse",
Self::Triangle => "Triangle",
Self::Parallelogram => "Parallelogram",
Self::Rhombus => "Rhombus",
Self::RegularPolygon => "Regular polygon",
Self::Arrow => "Arrow",
Self::Blur => "Blur",
Self::Marker => "Marker",
@@ -193,6 +234,10 @@ impl DragToolOption {
Self::Line => DragTool::Line,
Self::Rect => DragTool::Rect,
Self::Ellipse => DragTool::Ellipse,
Self::Triangle => DragTool::Triangle,
Self::Parallelogram => DragTool::Parallelogram,
Self::Rhombus => DragTool::Rhombus,
Self::RegularPolygon => DragTool::RegularPolygon,
Self::Arrow => DragTool::Arrow,
Self::Blur => DragTool::Blur,
Self::Marker => DragTool::Marker,
@@ -210,6 +255,10 @@ impl DragToolOption {
DragTool::Line => Self::Line,
DragTool::Rect => Self::Rect,
DragTool::Ellipse => Self::Ellipse,
DragTool::Triangle => Self::Triangle,
DragTool::Parallelogram => Self::Parallelogram,
DragTool::Rhombus => Self::Rhombus,
DragTool::RegularPolygon => Self::RegularPolygon,
DragTool::Arrow => Self::Arrow,
DragTool::Blur => Self::Blur,
DragTool::Marker => Self::Marker,
@@ -227,6 +276,10 @@ impl DragToolOption {
Self::Line => Some(ToolOption::Line),
Self::Rect => Some(ToolOption::Rect),
Self::Ellipse => Some(ToolOption::Ellipse),
Self::Triangle => Some(ToolOption::Triangle),
Self::Parallelogram => Some(ToolOption::Parallelogram),
Self::Rhombus => Some(ToolOption::Rhombus),
Self::RegularPolygon => Some(ToolOption::RegularPolygon),
Self::Arrow => Some(ToolOption::Arrow),
Self::Blur => Some(ToolOption::Blur),
Self::Marker => Some(ToolOption::Marker),
+16 -3
View File
@@ -44,9 +44,12 @@ default_eraser_mode = "brush"
# Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha.
marker_opacity = 0.32
# Default fill state for rectangle/ellipse tools
# Default fill state for fill-capable shape tools
default_fill_enabled = false
# Default side count for the Regular Polygon tool (3 - 12)
polygon_sides = 5
# Default font size for text mode (8.0 - 72.0)
# Can be adjusted at runtime with <kbd>Ctrl+Shift++</kbd>/<kbd>Ctrl+Shift+-</kbd> or <kbd>Shift</kbd> + scroll
default_font_size = 32.0
@@ -63,6 +66,8 @@ hit_test_linear_threshold = 400
undo_stack_limit = 100
# Drag gesture tool mapping
# Flat drag fields accept only drag-bindable tools. Freeform polygon is
# selectable from the toolbar picker but is not valid here.
drag_tool = "pen"
shift_drag_tool = "line"
ctrl_drag_tool = "rect"
@@ -93,6 +98,7 @@ drag_tool = "default"
- **Eraser size**: Use <kbd>+</kbd>/<kbd>-</kbd> keys or scroll wheel when eraser tool is active (range: 1-50px)
- **Eraser mode**: Use <kbd>Ctrl+Shift+E</kbd> to toggle brush vs stroke erasing
- **Marker opacity**: Use <kbd>Ctrl+Alt</kbd> + <kbd>↑</kbd>/<kbd>↓</kbd>
- **Regular polygon sides**: Use the side toolbar Sides control (range: 3-12)
- **Font size**: Use <kbd>Ctrl+Shift++</kbd>/<kbd>Ctrl+Shift+-</kbd> or <kbd>Shift</kbd> + scroll (range: 8-72px)
**Defaults:**
@@ -102,6 +108,7 @@ drag_tool = "default"
- Eraser mode: Brush
- Marker opacity: 0.32
- Fill enabled: false
- Polygon sides: 5
- Font size: 32.0px
- Font family/weight/style: Sans / bold / normal
- Text background: false
@@ -191,7 +198,7 @@ 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`, `show_status_bar`, `drag_tools`
**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`
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
@@ -565,6 +572,7 @@ force_inline = false
- **Settings**: `show_settings_section` hides/shows the settings footer (config buttons and toggles).
- **Delays**: `show_delay_sliders` shows the timed undo/redo-all sliders in the side panel.
- **Marker opacity**: the marker opacity slider appears when the marker tool is active; `show_marker_opacity_section` keeps it visible even when using other tools.
- **Polygon tools**: Full mode shows Triangle, Parallelogram, Rhombus, Regular Polygon, and Freeform Polygon under the compact Polygons picker. Simple mode exposes them in the Shapes picker.
- **Context-aware UI**: `context_aware_ui` shows/hides tool-specific controls (colors, thickness, arrow labels, etc.) based on the active tool; disable to always show all controls.
- **Preset toasts**: `show_preset_toasts` enables toast confirmations for preset apply/save/clear.
- **Tool preview**: `show_tool_preview` toggles the cursor bubble.
@@ -966,6 +974,11 @@ toggle_eraser_mode = ["Ctrl+Shift+E"]
select_line_tool = []
select_rect_tool = []
select_ellipse_tool = []
select_triangle_tool = []
select_parallelogram_tool = []
select_rhombus_tool = []
select_regular_polygon_tool = []
select_freeform_polygon_tool = []
select_arrow_tool = []
select_blur_tool = []
select_highlight_tool = []
@@ -1036,7 +1049,7 @@ render_profile_off = []
# Toggle click highlight (visual mouse halo)
toggle_click_highlight = ["Ctrl+Shift+H"]
# Toggle fill for rectangle/ellipse
# Toggle fill for fill-capable shapes
toggle_fill = []
# Optional keyboard binding to toggle radial menu at cursor
@@ -2,7 +2,7 @@ use log::warn;
use std::collections::HashMap;
use crate::config::{Action, Config, KeyBinding, KeybindingsConfig};
use crate::draw::FontDescriptor;
use crate::draw::{FontDescriptor, clamp_regular_sides};
use crate::input::{ClickHighlightSettings, DragToolBindings, InputState};
pub(super) fn build_input_state(config: &Config) -> InputState {
@@ -51,6 +51,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState {
input_state.set_hit_test_tolerance(config.drawing.hit_test_tolerance);
input_state.set_hit_test_threshold(config.drawing.hit_test_linear_threshold);
input_state.set_undo_stack_limit(config.drawing.undo_stack_limit);
input_state.polygon_sides = clamp_regular_sides(config.drawing.polygon_sides);
input_state.set_context_menu_enabled(config.ui.context_menu.enabled);
input_state.show_status_board_badge = config.ui.show_status_board_badge;
input_state.show_status_page_badge = config.ui.show_status_page_badge;
@@ -201,11 +202,11 @@ mod tests {
#[test]
fn build_input_state_applies_drag_tool_bindings() {
let mut config = Config::default();
config.drawing.drag_tool = crate::input::Tool::Arrow;
config.drawing.shift_drag_tool = crate::input::Tool::Eraser;
config.drawing.ctrl_drag_tool = crate::input::Tool::Pen;
config.drawing.ctrl_shift_drag_tool = crate::input::Tool::Rect;
config.drawing.tab_drag_tool = crate::input::Tool::Ellipse;
config.drawing.drag_tool = crate::input::DragBindableTool::Arrow;
config.drawing.shift_drag_tool = crate::input::DragBindableTool::Eraser;
config.drawing.ctrl_drag_tool = crate::input::DragBindableTool::Pen;
config.drawing.ctrl_shift_drag_tool = crate::input::DragBindableTool::Rect;
config.drawing.tab_drag_tool = crate::input::DragBindableTool::Ellipse;
let input = build_input_state(&config);
assert_eq!(
@@ -163,6 +163,9 @@ impl WaylandState {
DrawingState::Drawing { .. } => {
return CursorIcon::Crosshair;
}
DrawingState::BuildingPolygon { .. } => {
return CursorIcon::Crosshair;
}
// Selecting (marquee) - use crosshair
DrawingState::Selecting { .. } => {
return CursorIcon::Crosshair;
@@ -71,6 +71,15 @@ fn draw_semantic_tool_icon(
SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size),
SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size),
SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size),
SemanticToolIcon::Triangle => toolbar_icons::draw_icon_triangle(ctx, x, y, size),
SemanticToolIcon::Parallelogram => {
toolbar_icons::draw_icon_parallelogram(ctx, x, y, size);
}
SemanticToolIcon::Rhombus => toolbar_icons::draw_icon_rhombus(ctx, x, y, size),
SemanticToolIcon::Polygon => toolbar_icons::draw_icon_polygon(ctx, x, y, size),
SemanticToolIcon::FreeformPolygon => {
toolbar_icons::draw_icon_freeform_polygon(ctx, x, y, size);
}
SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size),
SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size),
SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size),
@@ -40,6 +40,37 @@ pub(super) fn push_thickness_hits(
tooltip: None,
});
let mut next_y = y + ToolbarLayoutSpec::SIDE_SLIDER_CARD_HEIGHT + ctx.section_gap;
if crate::ui::toolbar::snapshot::ToolContext::from_snapshot(ctx.snapshot)
.show_polygon_sides_control
{
next_y = push_polygon_sides_hits(ctx, next_y, hits);
}
next_y
}
fn push_polygon_sides_hits(ctx: &SideLayoutContext<'_>, y: f64, hits: &mut Vec<HitRegion>) -> f64 {
let row_y = y + ToolbarLayoutSpec::SIDE_SLIDER_ROW_OFFSET;
let btn = ToolbarLayoutSpec::SIDE_NUDGE_SIZE;
hits.push(HitRegion {
rect: (ctx.x, row_y, btn, btn),
event: ToolbarEvent::NudgePolygonSides(-1),
kind: HitKind::Click,
tooltip: Some("Decrease polygon sides".to_string()),
});
hits.push(HitRegion {
rect: (ctx.x + ctx.content_width - btn, row_y, btn, btn),
event: ToolbarEvent::NudgePolygonSides(1),
kind: HitKind::Click,
tooltip: Some("Increase polygon sides".to_string()),
});
hits.push(HitRegion {
rect: (ctx.x + btn, row_y, ctx.content_width - btn * 2.0, btn),
event: ToolbarEvent::SetPolygonSides(ctx.snapshot.polygon_sides),
kind: HitKind::Click,
tooltip: None,
});
y + ToolbarLayoutSpec::SIDE_SLIDER_CARD_HEIGHT + ctx.section_gap
}
@@ -51,6 +51,9 @@ impl ToolbarLayoutSpec {
if tool_context.show_eraser_mode {
add_section(Self::SIDE_ERASER_MODE_CARD_HEIGHT, &mut height);
}
if tool_context.show_polygon_sides_control {
add_section(Self::SIDE_SLIDER_CARD_HEIGHT, &mut height);
}
}
// Arrow controls: only when arrow tool is active
+16 -9
View File
@@ -37,13 +37,18 @@ impl ToolbarLayoutSpec {
Self::TOP_SIZE_TEXT.1
};
let mut height = base_height as f64;
if self.layout_mode == ToolbarLayoutMode::Simple && self.shape_picker_open {
if self.shape_picker_open {
let (_, btn_h) = if self.use_icons {
(Self::TOP_ICON_BUTTON, Self::TOP_ICON_BUTTON)
} else {
(Self::TOP_TEXT_BUTTON_W, Self::TOP_TEXT_BUTTON_H)
};
height += btn_h + Self::TOP_SHAPE_ROW_GAP;
let row_count = if self.layout_mode == ToolbarLayoutMode::Simple {
2.0
} else {
1.0
};
height += row_count * (btn_h + Self::TOP_SHAPE_ROW_GAP);
}
let gap = Self::TOP_GAP;
@@ -56,14 +61,10 @@ impl ToolbarLayoutSpec {
model::top_tool_buttons(self.layout_mode == ToolbarLayoutMode::Simple).len();
let mut x = Self::TOP_START_X + Self::TOP_HANDLE_SIZE + gap;
x += tool_count as f64 * (btn_w + gap);
if self.layout_mode == ToolbarLayoutMode::Simple {
x += btn_w + gap;
}
x += btn_w + gap; // Shapes/Polygons picker
let fill_tool_active =
model::fill_tool_active(snapshot.active_tool, snapshot.tool_override);
let fill_visible = !self.use_icons
&& fill_tool_active
&& !(self.layout_mode == ToolbarLayoutMode::Simple && self.shape_picker_open);
let fill_visible = !self.use_icons && fill_tool_active && !self.shape_picker_open;
if fill_visible {
x += Self::TOP_TEXT_FILL_W + gap;
}
@@ -97,7 +98,13 @@ impl ToolbarLayoutSpec {
Self::TOP_ICON_BUTTON_Y
} else {
let (_, btn_h) = self.top_button_size();
(height - btn_h) / 2.0
let base_height = Self::TOP_SIZE_TEXT.1 as f64;
let available = if self.shape_picker_open {
base_height
} else {
height
};
(available - btn_h) / 2.0
}
}
@@ -68,11 +68,11 @@ fn top_size_respects_icon_mode() {
let mut state = create_test_input_state();
state.toolbar_use_icons = true;
let snapshot = snapshot_from_state(&state);
assert_eq!(top_size(&snapshot), (914, 72));
assert_eq!(top_size(&snapshot), (965, 72));
state.toolbar_use_icons = false;
let snapshot = snapshot_from_state(&state);
assert_eq!(top_size(&snapshot), (1045, 60));
assert_eq!(top_size(&snapshot), (1110, 60));
}
#[test]
+57 -17
View File
@@ -57,12 +57,26 @@ pub(super) fn build_hits(
fill_anchor = Some((x, btn_size));
}
x += btn_size + gap;
} else if let (Some(rect_x), Some(circle_end_x)) = (rect_x, circle_end_x) {
fill_anchor = Some((rect_x, circle_end_x - rect_x));
} else {
let current_shape_tool =
model::current_shape_tool(snapshot.active_tool, snapshot.tool_override);
let current_polygon_tool = current_shape_tool.filter(|tool| model::is_polygon_tool(*tool));
hits.push(HitRegion {
rect: (x, y, btn_size, btn_size),
event: ToolbarEvent::ToggleShapePicker(!snapshot.shape_picker_open),
kind: HitKind::Click,
tooltip: Some("Polygons".to_string()),
});
if current_polygon_tool.is_some() {
fill_anchor = Some((x, btn_size));
} else if let (Some(rect_x), Some(circle_end_x)) = (rect_x, circle_end_x) {
fill_anchor = Some((rect_x, circle_end_x - rect_x));
}
x += btn_size + gap;
}
if fill_tool_active
&& !(is_simple && snapshot.shape_picker_open)
&& !snapshot.shape_picker_open
&& let Some((fill_x, fill_w)) = fill_anchor
{
let fill_y = y + btn_size + ToolbarLayoutSpec::TOP_ICON_FILL_OFFSET;
@@ -160,20 +174,46 @@ pub(super) fn build_hits(
tooltip: None,
});
if is_simple && snapshot.shape_picker_open {
let shape_y = y + btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + ToolbarLayoutSpec::TOP_HANDLE_SIZE + gap;
for tool in shape_buttons() {
hits.push(HitRegion {
rect: (shape_x, shape_y, btn_size, btn_size),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(format_binding_label(
tool_tooltip_label(*tool),
snapshot.binding_hints.for_tool(*tool),
)),
});
shape_x += btn_size + gap;
if snapshot.shape_picker_open {
let mut shape_y = y + btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
push_picker_hits(
shape_y,
btn_size,
gap,
if is_simple {
model::common_shape_tools()
} else {
shape_buttons()
},
snapshot,
hits,
);
if is_simple {
shape_y += btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
push_picker_hits(shape_y, btn_size, gap, shape_buttons(), snapshot, hits);
}
}
}
fn push_picker_hits(
shape_y: f64,
btn_size: f64,
gap: f64,
tools: &[crate::input::Tool],
snapshot: &ToolbarSnapshot,
hits: &mut Vec<HitRegion>,
) {
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + ToolbarLayoutSpec::TOP_HANDLE_SIZE + gap;
for tool in tools {
hits.push(HitRegion {
rect: (shape_x, shape_y, btn_size, btn_size),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(format_binding_label(
tool_tooltip_label(*tool),
snapshot.binding_hints.for_tool(*tool),
)),
});
shape_x += btn_size + gap;
}
}
@@ -56,5 +56,5 @@ fn tool_buttons(is_simple: bool) -> &'static [Tool] {
}
fn shape_buttons() -> &'static [Tool] {
model::shape_tools()
model::polygon_tools()
}
+53 -16
View File
@@ -6,6 +6,7 @@ use super::shape_buttons;
use super::tool_buttons;
use crate::config::{Action, action_label};
use crate::ui::toolbar::bindings::tool_tooltip_label;
use crate::ui::toolbar::model;
use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot};
pub(super) fn build_hits(
@@ -45,9 +46,17 @@ pub(super) fn build_hits(
tooltip: Some("Shapes".to_string()),
});
x += btn_w + gap;
} else {
hits.push(HitRegion {
rect: (x, y, btn_w, btn_h),
event: ToolbarEvent::ToggleShapePicker(!snapshot.shape_picker_open),
kind: HitKind::Click,
tooltip: Some("Polygons".to_string()),
});
x += btn_w + gap;
}
if fill_tool_active {
if fill_tool_active && !snapshot.shape_picker_open {
let fill_w = ToolbarLayoutSpec::TOP_TEXT_FILL_W;
hits.push(HitRegion {
rect: (x, y, fill_w, btn_h),
@@ -111,21 +120,49 @@ pub(super) fn build_hits(
tooltip: None,
});
if is_simple && snapshot.shape_picker_open {
let shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + ToolbarLayoutSpec::TOP_HANDLE_SIZE + gap;
for tool in shape_buttons() {
let tooltip_label = tool_tooltip_label(*tool);
hits.push(HitRegion {
rect: (shape_x, shape_y, btn_w, btn_h),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(format_binding_label(
tooltip_label,
snapshot.binding_hints.for_tool(*tool),
)),
});
shape_x += btn_w + gap;
if snapshot.shape_picker_open {
let mut shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
push_picker_hits(
shape_y,
btn_w,
btn_h,
gap,
if is_simple {
model::common_shape_tools()
} else {
shape_buttons()
},
snapshot,
hits,
);
if is_simple {
shape_y += btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
push_picker_hits(shape_y, btn_w, btn_h, gap, shape_buttons(), snapshot, hits);
}
}
}
fn push_picker_hits(
shape_y: f64,
btn_w: f64,
btn_h: f64,
gap: f64,
tools: &[crate::input::Tool],
snapshot: &ToolbarSnapshot,
hits: &mut Vec<HitRegion>,
) {
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + ToolbarLayoutSpec::TOP_HANDLE_SIZE + gap;
for tool in tools {
let tooltip_label = tool_tooltip_label(*tool);
hits.push(HitRegion {
rect: (shape_x, shape_y, btn_w, btn_h),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(format_binding_label(
tooltip_label,
snapshot.binding_hints.for_tool(*tool),
)),
});
shape_x += btn_w + gap;
}
}
@@ -121,6 +121,15 @@ fn draw_preset_icon(ctx: &cairo::Context, tool: Tool, x: f64, y: f64, size: f64)
SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size),
SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size),
SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size),
SemanticToolIcon::Triangle => toolbar_icons::draw_icon_triangle(ctx, x, y, size),
SemanticToolIcon::Parallelogram => {
toolbar_icons::draw_icon_parallelogram(ctx, x, y, size);
}
SemanticToolIcon::Rhombus => toolbar_icons::draw_icon_rhombus(ctx, x, y, size),
SemanticToolIcon::Polygon => toolbar_icons::draw_icon_polygon(ctx, x, y, size),
SemanticToolIcon::FreeformPolygon => {
toolbar_icons::draw_icon_freeform_polygon(ctx, x, y, size);
}
SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size),
SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size),
SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size),
@@ -196,4 +196,101 @@ pub(super) fn draw_thickness_section(layout: &mut SidePaletteLayout, y: &mut f64
});
*y += eraser_card_h + section_gap;
}
if tool_context.show_polygon_sides_control {
draw_polygon_sides_section(layout, y, label_style);
}
}
fn draw_polygon_sides_section(
layout: &mut SidePaletteLayout,
y: &mut f64,
label_style: UiTextStyle,
) {
let ctx = layout.ctx;
let snapshot = layout.snapshot;
let hover = layout.hover;
let x = layout.x;
let card_x = layout.card_x;
let card_w = layout.card_w;
let content_width = layout.content_width;
let section_gap = layout.section_gap;
let width = layout.width;
let card_h = ToolbarLayoutSpec::SIDE_SLIDER_CARD_HEIGHT;
let btn_size = ToolbarLayoutSpec::SIDE_NUDGE_SIZE;
let icon_size = ToolbarLayoutSpec::SIDE_NUDGE_ICON_SIZE;
let value_w = ToolbarLayoutSpec::SIDE_SLIDER_VALUE_WIDTH;
let row_y = *y + ToolbarLayoutSpec::SIDE_SLIDER_ROW_OFFSET;
draw_group_card(ctx, card_x, *y, card_w, card_h);
draw_section_label(
ctx,
label_style,
x,
*y + ToolbarLayoutSpec::SIDE_SECTION_LABEL_OFFSET_Y,
"Sides",
);
let minus_x = x;
let minus_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, minus_x, row_y, btn_size, btn_size))
.unwrap_or(false);
draw_button(ctx, minus_x, row_y, btn_size, btn_size, false, minus_hover);
set_icon_color(ctx, minus_hover);
toolbar_icons::draw_icon_minus(
ctx,
minus_x + (btn_size - icon_size) / 2.0,
row_y + (btn_size - icon_size) / 2.0,
icon_size,
);
layout.hits.push(HitRegion {
rect: (minus_x, row_y, btn_size, btn_size),
event: ToolbarEvent::NudgePolygonSides(-1),
kind: HitKind::Click,
tooltip: Some("Decrease polygon sides".to_string()),
});
let plus_x = width - x - btn_size - value_w - 4.0;
let plus_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, plus_x, row_y, btn_size, btn_size))
.unwrap_or(false);
draw_button(ctx, plus_x, row_y, btn_size, btn_size, false, plus_hover);
set_icon_color(ctx, plus_hover);
toolbar_icons::draw_icon_plus(
ctx,
plus_x + (btn_size - icon_size) / 2.0,
row_y + (btn_size - icon_size) / 2.0,
icon_size,
);
layout.hits.push(HitRegion {
rect: (plus_x, row_y, btn_size, btn_size),
event: ToolbarEvent::NudgePolygonSides(1),
kind: HitKind::Click,
tooltip: Some("Increase polygon sides".to_string()),
});
let value_x = width - x - value_w;
draw_label_center(
ctx,
label_style,
value_x,
row_y,
value_w,
btn_size,
&snapshot.polygon_sides.to_string(),
);
layout.hits.push(HitRegion {
rect: (
minus_x + btn_size,
row_y,
content_width - btn_size * 2.0,
btn_size,
),
event: ToolbarEvent::SetPolygonSides(snapshot.polygon_sides),
kind: HitKind::Click,
tooltip: None,
});
*y += card_h + section_gap;
}
@@ -10,6 +10,7 @@ use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec;
use crate::config::{Action, action_label, action_short_label};
use crate::input::Tool;
use crate::ui::toolbar::ToolbarEvent;
use crate::ui::toolbar::model;
use crate::ui_text::UiTextStyle;
use super::super::widgets::constants::{FONT_FAMILY_DEFAULT, FONT_SIZE_SMALL};
@@ -24,7 +25,6 @@ pub(super) fn draw_icon_strip(
handle_w: f64,
is_simple: bool,
current_shape_tool: Option<Tool>,
shape_icon_tool: Tool,
fill_tool_active: bool,
) {
let ctx = layout.ctx;
@@ -48,6 +48,10 @@ pub(super) fn draw_icon_strip(
weight: cairo::FontWeight::Bold,
size: ICON_TOGGLE_FONT_SIZE,
};
let shape_icon_tool = current_shape_tool.unwrap_or_else(model::default_shape_tool);
let polygon_icon_tool = current_shape_tool
.filter(|tool| model::is_polygon_tool(*tool))
.unwrap_or_else(model::default_polygon_tool);
let tool_row = draw_tool_row(
layout,
@@ -59,13 +63,14 @@ pub(super) fn draw_icon_strip(
is_simple,
current_shape_tool,
shape_icon_tool,
polygon_icon_tool,
);
let divider_x = tool_row.next_x - gap * 0.5;
draw_divider_vertical(ctx, divider_x, y + 6.0, btn_size - 12.0);
x = tool_row.next_x;
if fill_tool_active
&& !(is_simple && snapshot.shape_picker_open)
&& !snapshot.shape_picker_open
&& let Some((fill_x, fill_w)) = tool_row.fill_anchor
{
let fill_y = y + btn_size + ToolbarLayoutSpec::TOP_ICON_FILL_OFFSET;
@@ -132,7 +137,7 @@ pub(super) fn draw_icon_strip(
tooltip: Some("Text mode".to_string()),
});
if is_simple && snapshot.shape_picker_open {
draw_shape_picker_row(layout, handle_w, y, btn_size, icon_size);
if snapshot.shape_picker_open {
draw_shape_picker_row(layout, handle_w, y, btn_size, icon_size, is_simple);
}
}
@@ -15,10 +15,44 @@ pub(super) fn draw_shape_picker_row(
y: f64,
btn_size: f64,
icon_size: f64,
is_simple: bool,
) {
let mut shape_y = y + btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
draw_picker_row(
layout,
handle_w,
shape_y,
btn_size,
icon_size,
if is_simple {
model::common_shape_tools()
} else {
model::polygon_tools()
},
);
if is_simple {
shape_y += btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
draw_picker_row(
layout,
handle_w,
shape_y,
btn_size,
icon_size,
model::polygon_tools(),
);
}
}
fn draw_picker_row(
layout: &mut TopStripLayout,
handle_w: f64,
shape_y: f64,
btn_size: f64,
icon_size: f64,
tools: &[crate::input::Tool],
) {
let shape_y = y + btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + layout.gap;
for tool in model::shape_tools() {
for tool in tools {
let is_active =
layout.snapshot.active_tool == *tool || layout.snapshot.tool_override == Some(*tool);
let is_hover = layout
@@ -25,6 +25,7 @@ pub(super) fn draw_tool_row(
is_simple: bool,
current_shape_tool: Option<Tool>,
shape_icon_tool: Tool,
polygon_icon_tool: Tool,
) -> ToolRowResult {
let snapshot = layout.snapshot;
@@ -100,8 +101,44 @@ pub(super) fn draw_tool_row(
});
fill_anchor = Some((x, btn_size));
x += btn_size + gap;
} else if let (Some(rect_x), Some(circle_end_x)) = (rect_x, circle_end_x) {
fill_anchor = Some((rect_x, circle_end_x - rect_x));
} else {
let current_polygon_tool = current_shape_tool.filter(|tool| model::is_polygon_tool(*tool));
let polygons_active = snapshot.shape_picker_open || current_polygon_tool.is_some();
let polygons_hover = layout
.hover
.map(|(hx, hy)| point_in_rect(hx, hy, x, y, btn_size, btn_size))
.unwrap_or(false);
draw_button(
layout.ctx,
x,
y,
btn_size,
btn_size,
polygons_active,
polygons_hover,
);
set_icon_color(layout.ctx, polygons_hover);
let icon_x = x + (btn_size - icon_size) / 2.0;
let icon_y = y + (btn_size - icon_size) / 2.0;
draw_semantic_tool_icon(
layout.ctx,
model::semantic_icon_for_tool(polygon_icon_tool),
icon_x,
icon_y,
icon_size,
);
layout.hits.push(HitRegion {
rect: (x, y, btn_size, btn_size),
event: ToolbarEvent::ToggleShapePicker(!snapshot.shape_picker_open),
kind: HitKind::Click,
tooltip: Some("Polygons".to_string()),
});
if current_polygon_tool.is_some() {
fill_anchor = Some((x, btn_size));
} else if let (Some(rect_x), Some(circle_end_x)) = (rect_x, circle_end_x) {
fill_anchor = Some((rect_x, circle_end_x - rect_x));
}
x += btn_size + gap;
}
ToolRowResult {
@@ -123,6 +160,15 @@ pub(crate) fn draw_semantic_tool_icon(
SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size),
SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size),
SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size),
SemanticToolIcon::Triangle => toolbar_icons::draw_icon_triangle(ctx, x, y, size),
SemanticToolIcon::Parallelogram => {
toolbar_icons::draw_icon_parallelogram(ctx, x, y, size);
}
SemanticToolIcon::Rhombus => toolbar_icons::draw_icon_rhombus(ctx, x, y, size),
SemanticToolIcon::Polygon => toolbar_icons::draw_icon_polygon(ctx, x, y, size),
SemanticToolIcon::FreeformPolygon => {
toolbar_icons::draw_icon_freeform_polygon(ctx, x, y, size);
}
SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size),
SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size),
SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size),
@@ -97,7 +97,6 @@ pub fn render_top_strip(
let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple;
let current_shape_tool =
model::current_shape_tool(snapshot.active_tool, snapshot.tool_override);
let shape_icon_tool = current_shape_tool.unwrap_or_else(model::default_shape_tool);
let fill_tool_active = model::fill_tool_active(snapshot.active_tool, snapshot.tool_override);
if snapshot.use_icons {
@@ -107,7 +106,6 @@ pub fn render_top_strip(
handle_w,
is_simple,
current_shape_tool,
shape_icon_tool,
fill_tool_active,
);
} else {
@@ -75,9 +75,24 @@ pub(super) fn draw_text_strip(
tooltip: Some("Shapes".to_string()),
});
x += btn_w + gap;
} else {
let current_polygon_tool = current_shape_tool.filter(|tool| model::is_polygon_tool(*tool));
let polygons_active = snapshot.shape_picker_open || current_polygon_tool.is_some();
let polygons_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, x, y, btn_w, btn_h))
.unwrap_or(false);
draw_button(ctx, x, y, btn_w, btn_h, polygons_active, polygons_hover);
draw_label_center(ctx, label_style, x, y, btn_w, btn_h, "Poly");
layout.hits.push(HitRegion {
rect: (x, y, btn_w, btn_h),
event: ToolbarEvent::ToggleShapePicker(!snapshot.shape_picker_open),
kind: HitKind::Click,
tooltip: Some("Polygons".to_string()),
});
x += btn_w + gap;
}
if fill_tool_active {
if fill_tool_active && !snapshot.shape_picker_open {
let fill_w = ToolbarLayoutSpec::TOP_TEXT_FILL_W;
let fill_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, x, y, fill_w, btn_h))
@@ -222,26 +237,66 @@ pub(super) fn draw_text_strip(
tooltip: Some("Text mode".to_string()),
});
if is_simple && snapshot.shape_picker_open {
let shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + gap;
for tool in model::shape_tools() {
let label = tool_label(*tool);
let tooltip_label = tool_tooltip_label(*tool);
let is_active = snapshot.active_tool == *tool || snapshot.tool_override == Some(*tool);
let is_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, shape_x, shape_y, btn_w, btn_h))
.unwrap_or(false);
draw_button(ctx, shape_x, shape_y, btn_w, btn_h, is_active, is_hover);
draw_label_center(ctx, label_style, shape_x, shape_y, btn_w, btn_h, label);
let tooltip = layout.tool_tooltip(*tool, tooltip_label);
layout.hits.push(HitRegion {
rect: (shape_x, shape_y, btn_w, btn_h),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(tooltip),
});
shape_x += btn_w + gap;
if snapshot.shape_picker_open {
let mut shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
draw_picker_text_row(
layout,
handle_w,
shape_y,
btn_w,
btn_h,
label_style,
if is_simple {
model::common_shape_tools()
} else {
model::polygon_tools()
},
);
if is_simple {
shape_y += btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP;
draw_picker_text_row(
layout,
handle_w,
shape_y,
btn_w,
btn_h,
label_style,
model::polygon_tools(),
);
}
}
}
fn draw_picker_text_row(
layout: &mut TopStripLayout,
handle_w: f64,
shape_y: f64,
btn_w: f64,
btn_h: f64,
label_style: UiTextStyle,
tools: &[Tool],
) {
let ctx = layout.ctx;
let hover = layout.hover;
let gap = layout.gap;
let snapshot = layout.snapshot;
let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + gap;
for tool in tools {
let label = tool_label(*tool);
let tooltip_label = tool_tooltip_label(*tool);
let is_active = snapshot.active_tool == *tool || snapshot.tool_override == Some(*tool);
let is_hover = hover
.map(|(hx, hy)| point_in_rect(hx, hy, shape_x, shape_y, btn_w, btn_h))
.unwrap_or(false);
draw_button(ctx, shape_x, shape_y, btn_w, btn_h, is_active, is_hover);
draw_label_center(ctx, label_style, shape_x, shape_y, btn_w, btn_h, label);
let tooltip = layout.tool_tooltip(*tool, tooltip_label);
layout.hits.push(HitRegion {
rect: (shape_x, shape_y, btn_w, btn_h),
event: ToolbarEvent::SelectTool(*tool),
kind: HitKind::Click,
tooltip: Some(tooltip),
});
shape_x += btn_w + gap;
}
}
+50
View File
@@ -71,6 +71,56 @@ pub const ENTRIES: &[ActionMeta] = &[
true,
true
),
meta!(
SelectTriangleTool,
"Triangle Tool",
Some("Triangle"),
"Draw triangles",
Tools,
true,
true,
true
),
meta!(
SelectParallelogramTool,
"Parallelogram Tool",
Some("Parallelogram"),
"Draw parallelograms",
Tools,
true,
true,
true
),
meta!(
SelectRhombusTool,
"Rhombus Tool",
Some("Rhombus"),
"Draw rhombuses",
Tools,
true,
true,
true
),
meta!(
SelectRegularPolygonTool,
"Regular Polygon Tool",
Some("Polygon"),
"Draw regular polygons",
Tools,
true,
true,
true
),
meta!(
SelectFreeformPolygonTool,
"Freeform Polygon Tool",
Some("Freeform"),
"Build polygons by clicking vertices",
Tools,
true,
true,
true
),
meta!(
SelectArrowTool,
"Arrow Tool",
+5
View File
@@ -50,6 +50,11 @@ pub enum Action {
SelectLineTool,
SelectRectTool,
SelectEllipseTool,
SelectTriangleTool,
SelectParallelogramTool,
SelectRhombusTool,
SelectRegularPolygonTool,
SelectFreeformPolygonTool,
SelectArrowTool,
SelectBlurTool,
SelectHighlightTool,
@@ -32,6 +32,20 @@ impl KeybindingsConfig {
inserter.insert_all(&self.tools.select_line_tool, Action::SelectLineTool)?;
inserter.insert_all(&self.tools.select_rect_tool, Action::SelectRectTool)?;
inserter.insert_all(&self.tools.select_ellipse_tool, Action::SelectEllipseTool)?;
inserter.insert_all(&self.tools.select_triangle_tool, Action::SelectTriangleTool)?;
inserter.insert_all(
&self.tools.select_parallelogram_tool,
Action::SelectParallelogramTool,
)?;
inserter.insert_all(&self.tools.select_rhombus_tool, Action::SelectRhombusTool)?;
inserter.insert_all(
&self.tools.select_regular_polygon_tool,
Action::SelectRegularPolygonTool,
)?;
inserter.insert_all(
&self.tools.select_freeform_polygon_tool,
Action::SelectFreeformPolygonTool,
)?;
inserter.insert_all(&self.tools.select_arrow_tool, Action::SelectArrowTool)?;
inserter.insert_all(&self.tools.select_blur_tool, Action::SelectBlurTool)?;
inserter.insert_all(
@@ -44,6 +44,21 @@ pub struct ToolKeybindingsConfig {
#[serde(default = "default_select_ellipse_tool")]
pub select_ellipse_tool: Vec<String>,
#[serde(default = "default_select_triangle_tool")]
pub select_triangle_tool: Vec<String>,
#[serde(default = "default_select_parallelogram_tool")]
pub select_parallelogram_tool: Vec<String>,
#[serde(default = "default_select_rhombus_tool")]
pub select_rhombus_tool: Vec<String>,
#[serde(default = "default_select_regular_polygon_tool")]
pub select_regular_polygon_tool: Vec<String>,
#[serde(default = "default_select_freeform_polygon_tool")]
pub select_freeform_polygon_tool: Vec<String>,
#[serde(default = "default_select_arrow_tool")]
pub select_arrow_tool: Vec<String>,
@@ -85,6 +100,11 @@ impl Default for ToolKeybindingsConfig {
select_line_tool: default_select_line_tool(),
select_rect_tool: default_select_rect_tool(),
select_ellipse_tool: default_select_ellipse_tool(),
select_triangle_tool: default_select_triangle_tool(),
select_parallelogram_tool: default_select_parallelogram_tool(),
select_rhombus_tool: default_select_rhombus_tool(),
select_regular_polygon_tool: default_select_regular_polygon_tool(),
select_freeform_polygon_tool: default_select_freeform_polygon_tool(),
select_arrow_tool: default_select_arrow_tool(),
select_blur_tool: default_select_blur_tool(),
select_highlight_tool: default_select_highlight_tool(),
+20
View File
@@ -50,6 +50,26 @@ pub(crate) fn default_select_ellipse_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_triangle_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_parallelogram_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_rhombus_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_regular_polygon_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_freeform_polygon_tool() -> Vec<String> {
Vec::new()
}
pub(crate) fn default_select_arrow_tool() -> Vec<String> {
Vec::new()
}
+37
View File
@@ -101,6 +101,43 @@ fn load_parses_mouse_button_drag_tool_bindings() {
});
}
#[test]
fn legacy_drag_fields_accept_drag_bindable_polygon_tools() {
let config: Config =
toml::from_str("[drawing]\ndrag_tool = 'regular-polygon'\nshift_drag_tool = 'triangle'\n")
.expect("drag-bindable polygon tools should parse");
assert_eq!(
config.drawing.drag_tool,
crate::input::DragBindableTool::RegularPolygon
);
assert_eq!(
config.drawing.shift_drag_tool,
crate::input::DragBindableTool::Triangle
);
let drag_tools = config.drawing.effective_drag_tools();
assert_eq!(
drag_tools.left.drag_tool,
crate::input::DragTool::RegularPolygon
);
assert_eq!(
drag_tools.left.shift_drag_tool,
crate::input::DragTool::Triangle
);
}
#[test]
fn drag_config_rejects_freeform_polygon() {
let legacy_err = toml::from_str::<Config>("[drawing]\ndrag_tool = 'freeform-polygon'\n")
.expect_err("freeform polygon must not parse in legacy drag fields");
assert!(legacy_err.to_string().contains("freeform-polygon"));
let per_button_err =
toml::from_str::<Config>("[drawing.drag_tools.left]\ndrag_tool = 'freeform-polygon'\n")
.expect_err("freeform polygon must not parse in per-button drag fields");
assert!(per_button_err.to_string().contains("freeform-polygon"));
}
#[test]
fn effective_drag_tools_preserve_legacy_left_when_only_right_is_configured() {
with_temp_config_home(|config_root| {
+36 -5
View File
@@ -6,6 +6,7 @@ fn validate_and_clamp_clamps_out_of_range_values() {
let mut config = Config::default();
config.drawing.default_thickness = 80.0;
config.drawing.default_font_size = 3.0;
config.drawing.polygon_sides = 2;
config.drawing.font_weight = "not-a-real-weight".to_string();
config.drawing.font_style = "diagonal".to_string();
config.arrow.length = 100.0;
@@ -21,6 +22,7 @@ fn validate_and_clamp_clamps_out_of_range_values() {
assert_eq!(config.drawing.default_thickness, MAX_STROKE_THICKNESS);
assert_eq!(config.drawing.default_font_size, 8.0);
assert_eq!(config.drawing.polygon_sides, 3);
assert_eq!(config.drawing.font_weight, "bold");
assert_eq!(config.drawing.font_style, "normal");
assert_eq!(config.arrow.length, 50.0);
@@ -57,6 +59,21 @@ fn validate_and_clamp_clamps_out_of_range_values() {
);
}
#[test]
fn drawing_polygon_sides_validation_keeps_supported_bounds() {
for supported in [3, 12] {
let mut config = Config::default();
config.drawing.polygon_sides = supported;
config.validate_and_clamp();
assert_eq!(config.drawing.polygon_sides, supported);
}
let mut config = Config::default();
config.drawing.polygon_sides = u8::MAX;
config.validate_and_clamp();
assert_eq!(config.drawing.polygon_sides, 12);
}
#[test]
fn validate_boards_uses_boundary_id_normalization() {
let mut config = Config {
@@ -448,6 +465,7 @@ fn validate_clamps_preset_fields() {
arrow_length: Some(100.0),
arrow_angle: Some(5.0),
arrow_head_at_end: None,
polygon_sides: Some(2),
show_status_bar: None,
drag_tools: None,
});
@@ -471,6 +489,7 @@ fn validate_clamps_preset_fields() {
assert_eq!(preset.font_size, Some(8.0));
assert_eq!(preset.arrow_length, Some(50.0));
assert_eq!(preset.arrow_angle, Some(15.0));
assert_eq!(preset.polygon_sides, Some(3));
}
#[test]
@@ -585,12 +604,24 @@ fn validate_does_not_clamp_autosave_interval_to_idle() {
fn drawing_drag_tool_defaults_match_legacy_mapping() {
let config = Config::default();
assert_eq!(config.drawing.drag_tool, crate::input::Tool::Pen);
assert_eq!(config.drawing.shift_drag_tool, crate::input::Tool::Line);
assert_eq!(config.drawing.ctrl_drag_tool, crate::input::Tool::Rect);
assert_eq!(
config.drawing.drag_tool,
crate::input::DragBindableTool::Pen
);
assert_eq!(
config.drawing.shift_drag_tool,
crate::input::DragBindableTool::Line
);
assert_eq!(
config.drawing.ctrl_drag_tool,
crate::input::DragBindableTool::Rect
);
assert_eq!(
config.drawing.ctrl_shift_drag_tool,
crate::input::Tool::Arrow
crate::input::DragBindableTool::Arrow
);
assert_eq!(
config.drawing.tab_drag_tool,
crate::input::DragBindableTool::Ellipse
);
assert_eq!(config.drawing.tab_drag_tool, crate::input::Tool::Ellipse);
}
+41 -31
View File
@@ -1,5 +1,6 @@
use crate::config::enums::ColorSpec;
use crate::input::{DragTool, EraserMode, Tool};
use crate::draw::shape::REGULAR_POLYGON_DEFAULT_SIDES;
use crate::input::{DragBindableTool, DragTool, EraserMode};
use serde::{Deserialize, Serialize};
/// Drawing-related settings.
@@ -34,6 +35,10 @@ pub struct DrawingConfig {
#[serde(default = "default_fill_enabled")]
pub default_fill_enabled: bool,
/// Default side count for the regular polygon tool (valid range: 3 - 12)
#[serde(default = "default_polygon_sides")]
pub polygon_sides: u8,
/// Default font size for text mode in points (valid range: 8.0 - 72.0)
#[serde(default = "default_font_size")]
pub default_font_size: f64,
@@ -52,23 +57,23 @@ pub struct DrawingConfig {
/// Tool used for drag with no modifier.
#[serde(default = "default_drag_tool")]
pub drag_tool: Tool,
pub drag_tool: DragBindableTool,
/// Tool used for Shift+drag.
#[serde(default = "default_shift_drag_tool")]
pub shift_drag_tool: Tool,
pub shift_drag_tool: DragBindableTool,
/// Tool used for Ctrl+drag.
#[serde(default = "default_ctrl_drag_tool")]
pub ctrl_drag_tool: Tool,
pub ctrl_drag_tool: DragBindableTool,
/// Tool used for Ctrl+Shift+drag.
#[serde(default = "default_ctrl_shift_drag_tool")]
pub ctrl_shift_drag_tool: Tool,
pub ctrl_shift_drag_tool: DragBindableTool,
/// Tool used for Tab+drag.
#[serde(default = "default_tab_drag_tool")]
pub tab_drag_tool: Tool,
pub tab_drag_tool: DragBindableTool,
/// Optional per-mouse-button drag tool mapping.
///
@@ -106,6 +111,7 @@ impl Default for DrawingConfig {
default_eraser_mode: default_eraser_mode(),
marker_opacity: default_marker_opacity(),
default_fill_enabled: default_fill_enabled(),
polygon_sides: default_polygon_sides(),
default_font_size: default_font_size(),
hit_test_tolerance: default_hit_test_tolerance(),
hit_test_linear_threshold: default_hit_test_threshold(),
@@ -148,11 +154,11 @@ pub struct MouseDragToolsConfig {
impl MouseDragToolsConfig {
pub fn from_legacy(
drag_tool: Tool,
shift_drag_tool: Tool,
ctrl_drag_tool: Tool,
ctrl_shift_drag_tool: Tool,
tab_drag_tool: Tool,
drag_tool: DragBindableTool,
shift_drag_tool: DragBindableTool,
ctrl_drag_tool: DragBindableTool,
ctrl_shift_drag_tool: DragBindableTool,
tab_drag_tool: DragBindableTool,
) -> Self {
Self {
left: DragButtonConfig::from_legacy(
@@ -277,22 +283,22 @@ pub struct DragButtonConfig {
impl DragButtonConfig {
pub fn from_legacy(
drag_tool: Tool,
shift_drag_tool: Tool,
ctrl_drag_tool: Tool,
ctrl_shift_drag_tool: Tool,
tab_drag_tool: Tool,
drag_tool: DragBindableTool,
shift_drag_tool: DragBindableTool,
ctrl_drag_tool: DragBindableTool,
ctrl_shift_drag_tool: DragBindableTool,
tab_drag_tool: DragBindableTool,
) -> Self {
Self {
drag_tool: DragTool::from_tool(drag_tool),
drag_tool: drag_tool.to_drag_tool(),
drag_color: None,
shift_drag_tool: DragTool::from_tool(shift_drag_tool),
shift_drag_tool: shift_drag_tool.to_drag_tool(),
shift_drag_color: None,
ctrl_drag_tool: DragTool::from_tool(ctrl_drag_tool),
ctrl_drag_tool: ctrl_drag_tool.to_drag_tool(),
ctrl_drag_color: None,
ctrl_shift_drag_tool: DragTool::from_tool(ctrl_shift_drag_tool),
ctrl_shift_drag_tool: ctrl_shift_drag_tool.to_drag_tool(),
ctrl_shift_drag_color: None,
tab_drag_tool: DragTool::from_tool(tab_drag_tool),
tab_drag_tool: tab_drag_tool.to_drag_tool(),
tab_drag_color: None,
}
}
@@ -404,6 +410,10 @@ fn default_fill_enabled() -> bool {
false
}
fn default_polygon_sides() -> u8 {
REGULAR_POLYGON_DEFAULT_SIDES
}
fn default_font_size() -> f64 {
32.0
}
@@ -436,24 +446,24 @@ fn default_undo_stack_limit() -> usize {
100
}
fn default_drag_tool() -> Tool {
Tool::Pen
fn default_drag_tool() -> DragBindableTool {
DragBindableTool::Pen
}
fn default_shift_drag_tool() -> Tool {
Tool::Line
fn default_shift_drag_tool() -> DragBindableTool {
DragBindableTool::Line
}
fn default_ctrl_drag_tool() -> Tool {
Tool::Rect
fn default_ctrl_drag_tool() -> DragBindableTool {
DragBindableTool::Rect
}
fn default_ctrl_shift_drag_tool() -> Tool {
Tool::Arrow
fn default_ctrl_shift_drag_tool() -> DragBindableTool {
DragBindableTool::Arrow
}
fn default_tab_drag_tool() -> Tool {
Tool::Ellipse
fn default_tab_drag_tool() -> DragBindableTool {
DragBindableTool::Ellipse
}
fn default_button_behavior_drag_tool() -> DragTool {
+4
View File
@@ -66,6 +66,10 @@ pub struct ToolPresetConfig {
#[serde(default)]
pub arrow_head_at_end: Option<bool>,
/// Optional regular polygon side count override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub polygon_sides: Option<u8>,
/// Optional status bar visibility override.
#[serde(default)]
pub show_status_bar: Option<bool>,
+16
View File
@@ -1,4 +1,5 @@
use super::Config;
use crate::draw::shape::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES};
use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
impl Config {
@@ -52,6 +53,21 @@ impl Config {
self.drawing.default_font_size = self.drawing.default_font_size.clamp(8.0, 72.0);
}
if !(REGULAR_POLYGON_MIN_SIDES..=REGULAR_POLYGON_MAX_SIDES)
.contains(&self.drawing.polygon_sides)
{
log::warn!(
"Invalid polygon_sides {}, clamping to {}-{} range",
self.drawing.polygon_sides,
REGULAR_POLYGON_MIN_SIDES,
REGULAR_POLYGON_MAX_SIDES
);
self.drawing.polygon_sides = self
.drawing
.polygon_sides
.clamp(REGULAR_POLYGON_MIN_SIDES, REGULAR_POLYGON_MAX_SIDES);
}
if !(1.0..=20.0).contains(&self.drawing.hit_test_tolerance) {
log::warn!(
"Invalid hit_test_tolerance {:.1}, clamping to 1.0-20.0 range",
+14
View File
@@ -1,4 +1,5 @@
use super::Config;
use crate::draw::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides};
use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
use super::super::types::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, ToolPresetConfig};
@@ -101,6 +102,19 @@ impl Config {
);
*angle = angle.clamp(15.0, 60.0);
}
if let Some(sides) = preset.polygon_sides.as_mut()
&& !(REGULAR_POLYGON_MIN_SIDES..=REGULAR_POLYGON_MAX_SIDES).contains(sides)
{
log::warn!(
"Invalid polygon_sides {} in preset slot {}, clamping to {}-{} range",
*sides,
slot,
REGULAR_POLYGON_MIN_SIDES,
REGULAR_POLYGON_MAX_SIDES
);
*sides = clamp_regular_sides(*sides);
}
};
if let Some(preset) = self.presets.slot_1.as_mut() {
+3 -2
View File
@@ -32,8 +32,9 @@ pub use render::{
};
#[allow(unused_imports)]
pub use shape::{
ArrowLabel, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel,
invalidate_text_cache,
ArrowLabel, EmbeddedImage, EraserBrush, EraserKind, PolygonKind, REGULAR_POLYGON_DEFAULT_SIDES,
REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, Shape, StepMarkerLabel,
clamp_regular_sides, invalidate_text_cache,
};
// Re-export color constants for public API (unused internally but part of public interface)
+1
View File
@@ -16,6 +16,7 @@ pub use background::{fill_transparent, render_board_background};
pub use blur::{BlurRectParams, render_blur_rect};
pub use highlight::render_click_highlight;
pub use pressure_strokes::render_freehand_pressure_borrowed;
pub(crate) use primitives::render_polygon_preview;
pub use selection::{render_selection_halo, render_selection_handles, selection_handle_rects};
pub use shapes::render_shape;
pub(crate) use strokes::render_eraser_stroke;
+88
View File
@@ -94,6 +94,70 @@ pub(super) fn render_ellipse(
let _ = ctx.stroke();
}
/// Render a closed polygon outline with optional fill.
#[allow(clippy::too_many_arguments)]
pub(super) fn render_polygon(
ctx: &cairo::Context,
points: &[(i32, i32)],
fill: bool,
color: Color,
thick: f64,
) {
if !crate::draw::shape::has_minimum_distinct_points(points) {
return;
}
let _ = ctx.save();
ctx.new_path();
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_cap(cairo::LineCap::Round);
ctx.set_line_join(cairo::LineJoin::Round);
ctx.move_to(points[0].0 as f64, points[0].1 as f64);
for &(x, y) in &points[1..] {
ctx.line_to(x as f64, y as f64);
}
ctx.close_path();
if fill {
let _ = ctx.fill_preserve();
}
let _ = ctx.stroke();
let _ = ctx.restore();
}
/// Render the in-progress freeform polygon preview without open-end round cap blobs.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_polygon_preview(
ctx: &cairo::Context,
points: &[(i32, i32)],
fill: bool,
color: Color,
thick: f64,
) {
if points.len() < 2 {
return;
}
let _ = ctx.save();
ctx.new_path();
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
ctx.set_line_width(thick);
ctx.set_line_cap(cairo::LineCap::Butt);
ctx.set_line_join(cairo::LineJoin::Round);
ctx.move_to(points[0].0 as f64, points[0].1 as f64);
for &(x, y) in &points[1..] {
ctx.line_to(x as f64, y as f64);
}
if crate::draw::shape::has_minimum_distinct_points(points) {
ctx.close_path();
if fill {
let _ = ctx.fill_preserve();
}
}
let _ = ctx.stroke();
let _ = ctx.restore();
}
/// Render an arrow (line with arrowhead pointing towards the tip)
#[allow(clippy::too_many_arguments)]
pub(super) fn render_arrow(
@@ -173,6 +237,30 @@ mod tests {
surface.data().unwrap()[offset]
}
#[test]
fn polygon_preview_uses_butt_caps_for_open_edges() {
let (mut surface, ctx) = surface_with_context(90, 70);
let red = Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
render_polygon_preview(&ctx, &[(20, 35), (70, 35)], false, red, 10.0);
drop(ctx);
assert_eq!(
alpha_at(&mut surface, 17, 35),
0,
"open polygon preview edges should not leave round endpoint blobs"
);
assert!(
alpha_at(&mut surface, 25, 35) > 0,
"open polygon preview edge should still render"
);
}
#[test]
fn ellipse_does_not_connect_to_existing_current_path() {
let (mut surface, ctx) = surface_with_context(120, 120);
+4 -1
View File
@@ -1,5 +1,5 @@
use super::highlight::render_click_highlight;
use super::primitives::{render_arrow, render_ellipse, render_line, render_rect};
use super::primitives::{render_arrow, render_ellipse, render_line, render_polygon, render_rect};
use super::strokes::render_freehand_borrowed;
use crate::draw::frame::DrawnShape;
use crate::draw::shape::{step_marker_outline_thickness, step_marker_radius};
@@ -82,6 +82,9 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) {
} => {
render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, glow, thick + outline_width);
}
Shape::Polygon { points, thick, .. } => {
render_polygon(ctx, points, false, glow, thick + outline_width);
}
Shape::Arrow {
x1,
y1,
+10 -1
View File
@@ -2,7 +2,7 @@ use super::blur::render_blur_placeholder;
use super::highlight::render_click_highlight;
use super::image::render_image_shape;
use super::pressure_strokes::render_freehand_pressure_borrowed;
use super::primitives::{render_arrow, render_ellipse, render_line, render_rect};
use super::primitives::{render_arrow, render_ellipse, render_line, render_polygon, render_rect};
use super::strokes::{render_freehand_borrowed, render_marker_stroke_borrowed};
use super::text::{render_sticky_note, render_text};
use crate::draw::Color;
@@ -66,6 +66,15 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) {
} => {
render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, *color, *thick);
}
Shape::Polygon {
points,
fill,
color,
thick,
..
} => {
render_polygon(ctx, points, *fill, *color, *thick);
}
Shape::Arrow {
x1,
y1,
+6
View File
@@ -2,16 +2,22 @@
mod arrow_label;
mod bounds;
mod polygon;
mod step_marker;
mod text;
mod text_cache;
mod types;
pub use polygon::{
PolygonKind, REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES,
REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides,
};
pub use text_cache::invalidate_text_cache;
pub use types::{ArrowLabel, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel};
pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_layout};
pub(crate) use bounds::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points};
pub(crate) use polygon::{PolygonTemplate, generated_points, has_minimum_distinct_points};
pub(crate) use step_marker::{step_marker_outline_thickness, step_marker_radius};
pub(crate) use text::{
bounding_box_for_sticky_note, bounding_box_for_text, sticky_note_layout,
+216
View File
@@ -0,0 +1,216 @@
use crate::util::Rect;
use serde::{Deserialize, Serialize};
pub const REGULAR_POLYGON_MIN_SIDES: u8 = 3;
pub const REGULAR_POLYGON_MAX_SIDES: u8 = 12;
pub const REGULAR_POLYGON_DEFAULT_SIDES: u8 = 5;
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub enum PolygonKind {
Triangle,
Parallelogram,
Rhombus,
Regular { sides: u8 },
Freeform,
}
impl PolygonKind {
pub fn label(self) -> &'static str {
match self {
Self::Triangle => "Triangle",
Self::Parallelogram => "Parallelogram",
Self::Rhombus => "Rhombus",
Self::Regular { .. } => "Regular Polygon",
Self::Freeform => "Freeform Polygon",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PolygonTemplate {
Triangle,
Parallelogram,
Rhombus,
Regular,
}
impl PolygonTemplate {
pub(crate) fn kind(self, regular_sides: u8) -> PolygonKind {
match self {
Self::Triangle => PolygonKind::Triangle,
Self::Parallelogram => PolygonKind::Parallelogram,
Self::Rhombus => PolygonKind::Rhombus,
Self::Regular => PolygonKind::Regular {
sides: clamp_regular_sides(regular_sides),
},
}
}
}
pub fn clamp_regular_sides(sides: u8) -> u8 {
sides.clamp(REGULAR_POLYGON_MIN_SIDES, REGULAR_POLYGON_MAX_SIDES)
}
pub(crate) fn generated_points(
template: PolygonTemplate,
start: (i32, i32),
end: (i32, i32),
regular_sides: u8,
) -> Vec<(i32, i32)> {
match template {
PolygonTemplate::Triangle => triangle_points(start, end),
PolygonTemplate::Parallelogram => parallelogram_points(start, end),
PolygonTemplate::Rhombus => rhombus_points(start, end),
PolygonTemplate::Regular => regular_polygon_points(start, end, regular_sides),
}
}
pub fn has_minimum_distinct_points(points: &[(i32, i32)]) -> bool {
if points.len() < 3 {
return false;
}
let mut distinct = Vec::with_capacity(3);
for point in points {
if !distinct.contains(point) {
distinct.push(*point);
if distinct.len() >= 3 {
return true;
}
}
}
false
}
pub(crate) fn bounding_box_for_polygon(points: &[(i32, i32)], thick: f64) -> Option<Rect> {
if !has_minimum_distinct_points(points) {
return None;
}
let mut min_x = points[0].0;
let mut min_y = points[0].1;
let mut max_x = points[0].0;
let mut max_y = points[0].1;
for &(x, y) in &points[1..] {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
let pad = ((thick / 2.0).ceil() as i32).max(1);
Rect::from_min_max(min_x - pad, min_y - pad, max_x + pad, max_y + pad)
}
fn triangle_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> {
let (min_x, max_x) = sorted_pair(start.0, end.0);
let (min_y, max_y) = sorted_pair(start.1, end.1);
let mid_x = midpoint_i32(min_x, max_x);
if end.1 >= start.1 {
vec![(mid_x, min_y), (max_x, max_y), (min_x, max_y)]
} else {
vec![(mid_x, max_y), (min_x, min_y), (max_x, min_y)]
}
}
fn parallelogram_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> {
let (min_x, max_x) = sorted_pair(start.0, end.0);
let (min_y, max_y) = sorted_pair(start.1, end.1);
let width = max_x - min_x;
let skew = (width.abs() / 4).max(1);
if end.0 >= start.0 {
vec![
(min_x + skew, min_y),
(max_x, min_y),
(max_x - skew, max_y),
(min_x, max_y),
]
} else {
vec![
(min_x, min_y),
(max_x - skew, min_y),
(max_x, max_y),
(min_x + skew, max_y),
]
}
}
fn rhombus_points(start: (i32, i32), end: (i32, i32)) -> Vec<(i32, i32)> {
let (min_x, max_x) = sorted_pair(start.0, end.0);
let (min_y, max_y) = sorted_pair(start.1, end.1);
let mid_x = midpoint_i32(min_x, max_x);
let mid_y = midpoint_i32(min_y, max_y);
vec![
(mid_x, min_y),
(max_x, mid_y),
(mid_x, max_y),
(min_x, mid_y),
]
}
fn regular_polygon_points(start: (i32, i32), end: (i32, i32), sides: u8) -> Vec<(i32, i32)> {
let sides = clamp_regular_sides(sides);
let center_x = (start.0 as f64 + end.0 as f64) / 2.0;
let center_y = (start.1 as f64 + end.1 as f64) / 2.0;
let radius_x = (end.0 - start.0).abs() as f64 / 2.0;
let radius_y = (end.1 - start.1).abs() as f64 / 2.0;
let radius = radius_x.min(radius_y);
let start_angle = -std::f64::consts::FRAC_PI_2;
(0..sides)
.map(|index| {
let angle = start_angle + std::f64::consts::TAU * f64::from(index) / f64::from(sides);
(
(center_x + angle.cos() * radius).round() as i32,
(center_y + angle.sin() * radius).round() as i32,
)
})
.collect()
}
const fn sorted_pair(a: i32, b: i32) -> (i32, i32) {
if a <= b { (a, b) } else { (b, a) }
}
fn midpoint_i32(a: i32, b: i32) -> i32 {
((i64::from(a) + i64::from(b)) / 2) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polygon_kind_uses_explicit_tagged_serialization() {
let json = serde_json::to_string(&PolygonKind::Triangle).unwrap();
assert_eq!(json, r#"{"type":"triangle"}"#);
}
#[test]
fn regular_polygon_kind_serializes_sides() {
let json = serde_json::to_string(&PolygonKind::Regular { sides: 6 }).unwrap();
assert_eq!(json, r#"{"type":"regular","sides":6}"#);
}
#[test]
fn validity_requires_three_distinct_points() {
assert!(!has_minimum_distinct_points(&[(1, 1), (1, 1), (2, 2)]));
assert!(has_minimum_distinct_points(&[
(1, 1),
(1, 1),
(2, 2),
(3, 3)
]));
}
#[test]
fn regular_sides_clamp_to_supported_range() {
assert_eq!(clamp_regular_sides(2), 3);
assert_eq!(clamp_regular_sides(9), 9);
assert_eq!(clamp_regular_sides(80), 12);
}
}
+65 -1
View File
@@ -1,6 +1,6 @@
use super::types::Shape;
use super::{EmbeddedImage, EraserBrush};
use crate::draw::{EraserKind, FontDescriptor, StepMarkerLabel, color::WHITE};
use crate::draw::{EraserKind, FontDescriptor, PolygonKind, StepMarkerLabel, color::WHITE};
use crate::util;
#[test]
@@ -107,6 +107,70 @@ fn ellipse_bounding_box_handles_radii_and_stroke() {
assert_eq!(rect.height, 42);
}
#[test]
fn polygon_bounding_box_covers_vertices_and_stroke() {
let shape = Shape::Polygon {
kind: PolygonKind::Triangle,
points: vec![(10, 20), (30, 40), (5, 35)],
fill: false,
color: WHITE,
thick: 6.0,
};
let rect = shape.bounding_box().expect("polygon should have bounds");
assert_eq!(rect.x, 2);
assert_eq!(rect.y, 17);
assert_eq!(rect.width, 31);
assert_eq!(rect.height, 26);
}
#[test]
fn polygon_shape_serializes_and_deserializes_with_points() {
let shape = Shape::Polygon {
kind: PolygonKind::Regular { sides: 6 },
points: vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)],
fill: true,
color: WHITE,
thick: 4.0,
};
let json = serde_json::to_string(&shape).expect("serialize polygon shape");
let restored: Shape = serde_json::from_str(&json).expect("deserialize polygon shape");
match restored {
Shape::Polygon {
kind,
points,
fill,
color,
thick,
} => {
assert_eq!(kind, PolygonKind::Regular { sides: 6 });
assert_eq!(
points,
vec![(10, 20), (30, 20), (40, 35), (30, 50), (10, 50), (0, 35)]
);
assert!(fill);
assert_eq!(color, WHITE);
assert_eq!(thick, 4.0);
}
other => panic!("expected polygon shape, got {other:?}"),
}
}
#[test]
fn invalid_polygon_has_no_bounds() {
let shape = Shape::Polygon {
kind: PolygonKind::Freeform,
points: vec![(10, 20), (10, 20), (30, 40)],
fill: false,
color: WHITE,
thick: 6.0,
};
assert!(shape.bounding_box().is_none());
}
#[test]
fn text_bounding_box_is_non_zero() {
let shape = Shape::Text {
+16
View File
@@ -2,6 +2,7 @@ use super::bounds::{
bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse,
bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect,
};
use super::polygon::{PolygonKind, bounding_box_for_polygon};
use super::step_marker::step_marker_bounds;
use super::text::{bounding_box_for_sticky_note, bounding_box_for_text};
use crate::draw::color::Color;
@@ -129,6 +130,19 @@ pub enum Shape {
/// Border thickness in pixels
thick: f64,
},
/// Generic closed polygon, including named generated polygons and freeform polygons.
Polygon {
/// Polygon metadata used by UI labels and future editing.
kind: PolygonKind,
/// Concrete persisted vertices. These are the source of truth for rendering.
points: Vec<(i32, i32)>,
/// Whether to fill the polygon.
fill: bool,
/// Border/fill color.
color: Color,
/// Border thickness in pixels.
thick: f64,
},
/// Arrow with directional head (drawn with Ctrl+Shift modifiers)
Arrow {
/// Starting X coordinate (arrowhead location)
@@ -300,6 +314,7 @@ impl Shape {
thick,
..
} => bounding_box_for_ellipse(*cx, *cy, *rx, *ry, *thick),
Shape::Polygon { points, thick, .. } => bounding_box_for_polygon(points, *thick),
Shape::Arrow {
x1,
y1,
@@ -369,6 +384,7 @@ impl Shape {
Shape::Line { .. } => "Line",
Shape::Rect { .. } => "Rectangle",
Shape::Ellipse { .. } => "Ellipse",
Shape::Polygon { kind, .. } => kind.label(),
Shape::Arrow { .. } => "Arrow",
Shape::BlurRect { .. } => "Blur",
Shape::Text { .. } => "Text",
+24
View File
@@ -58,6 +58,30 @@ pub(super) fn point_in_triangle(
u >= -EPS && v >= -EPS && (u + v) <= 1.0 + EPS
}
pub(super) fn point_in_polygon(point: (f64, f64), points: &[(i32, i32)]) -> bool {
if points.len() < 3 {
return false;
}
// Even-odd point targeting intentionally approximates Cairo's default non-zero fill rule;
// simple polygons match, but self-intersecting freeform polygons can differ.
let (px, py) = point;
let mut inside = false;
let mut previous = points[points.len() - 1];
for &current in points {
let (x1, y1) = (previous.0 as f64, previous.1 as f64);
let (x2, y2) = (current.0 as f64, current.1 as f64);
if (y1 > py) != (y2 > py) {
let intersection_x = (x2 - x1) * (py - y1) / (y2 - y1) + x1;
if px < intersection_x {
inside = !inside;
}
}
previous = current;
}
inside
}
pub(super) fn to_i32_pair(p: (f64, f64)) -> (i32, i32) {
(p.0.round() as i32, p.1.round() as i32)
}
+36
View File
@@ -68,6 +68,9 @@ pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool {
thick,
..
} => shapes::ellipse_outline_hit(*cx, *cy, *rx, *ry, *thick, point, tolerance),
Shape::Polygon { points, thick, .. } => {
shapes::polygon_outline_hit(points, *thick, point, tolerance)
}
Shape::Arrow {
x1,
y1,
@@ -151,3 +154,36 @@ pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool {
Shape::EraserStroke { .. } => false,
}
}
/// Returns `true` when a point should target a shape for selection or menus.
///
/// Stroke erasing intentionally keeps using `hit_test`, while direct point
/// targeting includes filled interiors for closed fill-capable shapes.
pub fn hit_test_for_point_targeting(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool {
if hit_test(shape, point, tolerance) {
return true;
}
match &shape.shape {
Shape::Rect {
x,
y,
w,
h,
fill: true,
..
} => shapes::rect_fill_hit(*x, *y, *w, *h, point),
Shape::Ellipse {
cx,
cy,
rx,
ry,
fill: true,
..
} => shapes::ellipse_fill_hit(*cx, *cy, *rx, *ry, point),
Shape::Polygon {
points, fill: true, ..
} => shapes::polygon_fill_hit(points, point),
_ => false,
}
}
+51 -2
View File
@@ -1,8 +1,8 @@
use crate::util;
use super::geometry::{
EPS, distance_point_to_point, distance_point_to_segment, p_as_i32, point_in_triangle,
to_i32_pair,
EPS, distance_point_to_point, distance_point_to_segment, p_as_i32, point_in_polygon,
point_in_triangle, to_i32_pair,
};
pub(super) fn freehand_hit(
@@ -81,6 +81,43 @@ pub(super) fn rect_outline_hit(
vertical_hit || horizontal_hit
}
pub(super) fn rect_fill_hit(x: i32, y: i32, w: i32, h: i32, point: (i32, i32)) -> bool {
if w == 0 || h == 0 {
return false;
}
let (left, right) = if w >= 0 { (x, x + w) } else { (x + w, x) };
let (top, bottom) = if h >= 0 { (y, y + h) } else { (y + h, y) };
point.0 >= left && point.0 <= right && point.1 >= top && point.1 <= bottom
}
pub(super) fn polygon_outline_hit(
points: &[(i32, i32)],
thickness: f64,
point: (i32, i32),
tolerance: f64,
) -> bool {
if !crate::draw::shape::has_minimum_distinct_points(points) {
return false;
}
let padded = tolerance.max(thickness / 2.0);
for edge in points.windows(2) {
if distance_point_to_segment(point, edge[0], edge[1]) <= padded {
return true;
}
}
distance_point_to_segment(point, points[points.len() - 1], points[0]) <= padded
}
pub(super) fn polygon_fill_hit(points: &[(i32, i32)], point: (i32, i32)) -> bool {
if !crate::draw::shape::has_minimum_distinct_points(points) {
return false;
}
point_in_polygon((point.0 as f64, point.1 as f64), points)
}
pub(super) fn ellipse_outline_hit(
cx: i32,
cy: i32,
@@ -119,6 +156,18 @@ pub(super) fn ellipse_outline_hit(
outer && !inner
}
pub(super) fn ellipse_fill_hit(cx: i32, cy: i32, rx: i32, ry: i32, point: (i32, i32)) -> bool {
if rx <= 0 || ry <= 0 {
return false;
}
let dx = point.0 as f64 - cx as f64;
let dy = point.1 as f64 - cy as f64;
let rx = rx as f64;
let ry = ry as f64;
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0 + EPS
}
pub(super) fn circle_hit(cx: i32, cy: i32, radius: f64, point: (i32, i32), tolerance: f64) -> bool {
let dx = point.0 as f64 - cx as f64;
let dy = point.1 as f64 - cy as f64;
+100 -2
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::draw::{
ArrowLabel, BLACK, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, FontDescriptor, Shape,
StepMarkerLabel,
ArrowLabel, BLACK, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, FontDescriptor,
PolygonKind, Shape, StepMarkerLabel,
};
#[test]
@@ -97,6 +97,104 @@ fn ellipse_hit_handles_zero_radius() {
assert!(!hit_test(&ellipse, (60, 90), 1.0));
}
#[test]
fn polygon_hit_tests_closed_outline_only() {
let polygon = DrawnShape {
id: 3,
shape: Shape::Polygon {
kind: PolygonKind::Triangle,
points: vec![(10, 10), (40, 10), (25, 40)],
fill: true,
color: BLACK,
thick: 2.0,
},
created_at: 0,
locked: false,
};
assert!(hit_test(&polygon, (25, 10), 1.0));
assert!(
!hit_test(&polygon, (25, 22), 1.0),
"generic hit testing remains outline-only for filled polygon interiors"
);
}
#[test]
fn point_targeting_hits_filled_rect_and_ellipse_interiors() {
let rect = DrawnShape {
id: 3,
shape: Shape::Rect {
x: 10,
y: 10,
w: 40,
h: 30,
fill: true,
color: BLACK,
thick: 2.0,
},
created_at: 0,
locked: false,
};
let ellipse = DrawnShape {
id: 4,
shape: Shape::Ellipse {
cx: 80,
cy: 70,
rx: 20,
ry: 12,
fill: true,
color: BLACK,
thick: 2.0,
},
created_at: 0,
locked: false,
};
assert!(!hit_test(&rect, (30, 25), 1.0));
assert!(hit_test_for_point_targeting(&rect, (30, 25), 1.0));
assert!(!hit_test(&ellipse, (80, 70), 1.0));
assert!(hit_test_for_point_targeting(&ellipse, (80, 70), 1.0));
}
#[test]
fn point_targeting_hits_filled_polygon_interior() {
let polygon = DrawnShape {
id: 3,
shape: Shape::Polygon {
kind: PolygonKind::Triangle,
points: vec![(10, 10), (40, 10), (25, 40)],
fill: true,
color: BLACK,
thick: 2.0,
},
created_at: 0,
locked: false,
};
assert!(
hit_test_for_point_targeting(&polygon, (25, 22), 1.0),
"filled polygon interiors should be directly targetable"
);
}
#[test]
fn invalid_polygon_hit_test_is_false() {
let polygon = DrawnShape {
id: 4,
shape: Shape::Polygon {
kind: PolygonKind::Freeform,
points: vec![(10, 10), (10, 10), (40, 10)],
fill: false,
color: BLACK,
thick: 2.0,
},
created_at: 0,
locked: false,
};
assert!(!hit_test(&polygon, (10, 10), 10.0));
}
#[test]
fn arrowhead_hit_detects_point_near_tip_and_rejects_distant_point() {
// Arrow pointing upwards from tail at (0, -20) to tip at (0, 0).
+1 -1
View File
@@ -28,7 +28,7 @@ pub use state::{
#[cfg(tablet)]
#[allow(unused_imports)]
pub use tablet::TabletSettings;
pub use tool::{DragTool, EraserMode, PerToolDrawingSettings, Tool};
pub use tool::{DragBindableTool, DragTool, EraserMode, PerToolDrawingSettings, Tool};
// Re-export for public API (unused internally but part of public interface)
#[allow(unused_imports)]
+1 -1
View File
@@ -36,7 +36,7 @@ impl DragBinding {
pub fn from_tool(tool: Tool) -> Self {
Self {
tool: DragTool::from_tool(tool),
tool: DragTool::from_tool(tool).unwrap_or(DragTool::Default),
color: None,
}
}
+2 -2
View File
@@ -15,6 +15,6 @@ pub(crate) use types::{
BlockedActionFeedback, BoardPickerClickState, ClipboardFingerprint, ClipboardPasteRequest,
DelayedHistory, HistoryMode, PasteAnchor, PendingBackendAction, PendingBoardDelete,
PendingClipboardFallback, PendingPageDelete, PendingSelectionClipboardPublish,
PresetFeedbackState, SelectionPublishState, TextClickState, TextEditEntryFeedback, ToastAction,
UiToastState, WayscriberClipboardSelection,
PolygonClickState, PresetFeedbackState, SelectionPublishState, TextClickState,
TextEditEntryFeedback, ToastAction, UiToastState, WayscriberClipboardSelection,
};
+3 -1
View File
@@ -9,7 +9,7 @@ use super::super::types::{
};
use super::structs::InputState;
use crate::config::{Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX, RadialMenuMouseBinding};
use crate::draw::{DirtyTracker, EraserKind, FontDescriptor};
use crate::draw::{DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES};
use crate::input::state::highlight::{ClickHighlightSettings, ClickHighlightState};
use crate::input::{
BoardManager,
@@ -133,6 +133,7 @@ impl InputState {
toolbar_top_visible: true,
toolbar_side_visible: true,
fill_enabled,
polygon_sides: REGULAR_POLYGON_DEFAULT_SIDES,
toolbar_top_pinned: true,
toolbar_side_pinned: true,
toolbar_use_icons: true, // Default to icon mode
@@ -206,6 +207,7 @@ impl InputState {
active_clipboard_paste_request_id: None,
last_capture_path: None,
last_text_click: None,
last_polygon_click: None,
last_board_picker_click: None,
text_edit_target: None,
text_edit_entry_feedback: None,
+8 -4
View File
@@ -14,10 +14,10 @@ use super::super::types::{
BlockedActionFeedback, BoardPickerClickState, ClipboardPasteRequest, CompositorCapabilities,
DelayedHistory, DrawingState, OutputFocusAction, PendingBackendAction, PendingBoardDelete,
PendingClipboardFallback, PendingOnboardingUsage, PendingPageDelete,
PendingSelectionClipboardPublish, PresetAction, PresetFeedbackState, PressureThicknessEditMode,
PressureThicknessEntryMode, SelectionAxis, SelectionPublishState, StatusChangeHighlight,
TextClickState, TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab, UiToastState,
ZoomAction,
PendingSelectionClipboardPublish, PolygonClickState, PresetAction, PresetFeedbackState,
PressureThicknessEditMode, PressureThicknessEntryMode, SelectionAxis, SelectionPublishState,
StatusChangeHighlight, TextClickState, TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab,
UiToastState, ZoomAction,
};
use crate::config::{
Action, BoardsConfig, KeyBinding, PresenterModeConfig, RadialMenuMouseBinding, ToolPresetConfig,
@@ -182,6 +182,8 @@ pub struct InputState {
pub toolbar_side_visible: bool,
/// Whether fill is enabled for fill-capable shapes (rect, ellipse)
pub fill_enabled: bool,
/// Current side count for regular polygon drawing.
pub polygon_sides: u8,
/// Whether the top toolbar is pinned (saved to config, opens at startup)
pub toolbar_top_pinned: bool,
/// Whether the side toolbar is pinned (saved to config, opens at startup)
@@ -330,6 +332,8 @@ pub struct InputState {
pub(in crate::input::state::core) last_capture_path: Option<PathBuf>,
/// Last text/note click used for double-click detection
pub(crate) last_text_click: Option<TextClickState>,
/// Last freeform polygon point click used for double-click completion.
pub(crate) last_polygon_click: Option<PolygonClickState>,
/// Last board picker row click used for double-click detection
pub(crate) last_board_picker_click: Option<BoardPickerClickState>,
/// Tracks an in-progress text edit target (existing shape to replace)
+21 -1
View File
@@ -16,7 +16,7 @@ pub const STATUS_CHANGE_HIGHLIGHT_MS: u64 = 300;
use crate::capture::{ImageOperationKind, file::FileSaveConfig};
use crate::config::{Action, ToolPresetConfig};
use crate::draw::frame::ShapeSnapshot;
use crate::draw::{Shape, ShapeId};
use crate::draw::{Color, Shape, ShapeId};
use crate::input::tool::Tool;
use crate::util::Rect;
use serde::{Deserialize, Serialize};
@@ -44,6 +44,19 @@ pub enum DrawingState {
/// Accumulated thickness values for freehand drawing (pressure sensitivity)
point_thicknesses: Vec<f32>,
},
/// Click-to-add freeform polygon construction.
BuildingPolygon {
/// Committed polygon vertices.
points: Vec<(i32, i32)>,
/// Current pointer location used for the preview edge.
preview: Option<(i32, i32)>,
/// Fill setting frozen at the first click.
fill: bool,
/// Color frozen at the first click.
color: Color,
/// Stroke thickness frozen at the first click.
thick: f64,
},
/// Text input mode - user is typing text to place on screen
TextInput {
/// X coordinate where text will be placed
@@ -250,6 +263,13 @@ pub(crate) struct BoardPickerClickState {
pub at: Instant,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PolygonClickState {
pub x: i32,
pub y: i32,
pub at: Instant,
}
/// Tracks in-progress delayed undo/redo playback.
pub(crate) struct DelayedHistory {
pub mode: HistoryMode,
+17 -1
View File
@@ -1,5 +1,8 @@
use super::base::{DrawingState, InputState, TextInputMode};
use crate::draw::shape::{bounding_box_for_sticky_note, bounding_box_for_text};
use crate::draw::shape::{
bounding_box_for_points, bounding_box_for_sticky_note, bounding_box_for_text,
};
use crate::input::tool::PROVISIONAL_POLYGON_DAMAGE_PADDING;
use crate::util::Rect;
impl InputState {
@@ -38,6 +41,19 @@ impl InputState {
start_x, start_y, ..
} => Self::selection_rect_from_points(*start_x, *start_y, current_x, current_y)
.and_then(|rect| rect.inflated(2)),
DrawingState::BuildingPolygon {
points,
preview,
thick,
..
} => {
let mut preview_points = points.clone();
if let Some(point) = preview.or(Some((current_x, current_y))) {
preview_points.push(point);
}
bounding_box_for_points(&preview_points, *thick)
.and_then(|rect| rect.inflated(PROVISIONAL_POLYGON_DAMAGE_PADDING))
}
_ => None,
}
}
+8 -2
View File
@@ -172,7 +172,10 @@ impl InputState {
let bounds = cached.or_else(|| hit_test::compute_hit_bounds(drawn, tolerance));
let hit = bounds
.as_ref()
.map(|rect| rect.contains(x, y) && hit_test::hit_test(drawn, (x, y), tolerance))
.map(|rect| {
rect.contains(x, y)
&& hit_test::hit_test_for_point_targeting(drawn, (x, y), tolerance)
})
.unwrap_or(false);
(drawn.id, bounds, hit)
};
@@ -197,7 +200,10 @@ impl InputState {
let hit = bounds
.as_ref()
.map(|rect| rect.contains(x, y) && hit_test::hit_test(drawn, (x, y), tolerance))
.map(|rect| {
rect.contains(x, y)
&& hit_test::hit_test_for_point_targeting(drawn, (x, y), tolerance)
})
.unwrap_or(false);
if let Some(bounds) = bounds {
+1 -1
View File
@@ -26,7 +26,7 @@ pub use base::{
PressureThicknessEntryMode, SelectionAxis, SelectionHandle, TextInputMode, ToolbarDrawerTab,
UI_TOAST_DURATION_MS, UiToastKind, ZoomAction,
};
pub(crate) use base::{BoardPickerClickState, TextClickState};
pub(crate) use base::{BoardPickerClickState, PolygonClickState, TextClickState};
pub(crate) use base::{
ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingBackendAction,
PendingSelectionClipboardPublish, SelectionPublishState, WayscriberClipboardSelection,
@@ -15,6 +15,7 @@ impl InputState {
| Shape::Line { .. }
| Shape::Rect { .. }
| Shape::Ellipse { .. }
| Shape::Polygon { .. }
| Shape::Arrow { .. }
| Shape::MarkerStroke { .. }
| Shape::Text { .. }
@@ -28,6 +29,7 @@ impl InputState {
| Shape::Line { color, .. }
| Shape::Rect { color, .. }
| Shape::Ellipse { color, .. }
| Shape::Polygon { color, .. }
| Shape::Arrow { color, .. }
| Shape::Text { color, .. }
| Shape::StepMarker { color, .. }
@@ -78,6 +80,7 @@ impl InputState {
| Shape::Line { .. }
| Shape::Rect { .. }
| Shape::Ellipse { .. }
| Shape::Polygon { .. }
| Shape::Arrow { .. }
| Shape::MarkerStroke { .. }
| Shape::Text { .. }
@@ -91,6 +94,7 @@ impl InputState {
| Shape::Line { color, .. }
| Shape::Rect { color, .. }
| Shape::Ellipse { color, .. }
| Shape::Polygon { color, .. }
| Shape::Arrow { color, .. }
| Shape::Text { color, .. }
| Shape::StepMarker { color, .. }
@@ -8,7 +8,9 @@ impl InputState {
) -> bool {
let target = if direction == 0 {
self.selection_bool_target(|shape| match shape {
Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } => Some(*fill),
Shape::Rect { fill, .. }
| Shape::Ellipse { fill, .. }
| Shape::Polygon { fill, .. } => Some(*fill),
_ => None,
})
} else {
@@ -21,9 +23,18 @@ impl InputState {
};
let result = self.apply_selection_change(
|shape| matches!(shape, Shape::Rect { .. } | Shape::Ellipse { .. }),
|shape| {
matches!(
shape,
Shape::Rect { .. } | Shape::Ellipse { .. } | Shape::Polygon { .. }
)
},
|shape| match shape {
Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } if *fill != target => {
Shape::Rect { fill, .. }
| Shape::Ellipse { fill, .. }
| Shape::Polygon { fill, .. }
if *fill != target =>
{
*fill = target;
true
}
@@ -23,6 +23,7 @@ impl InputState {
| Shape::Line { .. }
| Shape::Rect { .. }
| Shape::Ellipse { .. }
| Shape::Polygon { .. }
| Shape::Arrow { .. }
| Shape::BlurRect { .. }
| Shape::MarkerStroke { .. }
@@ -33,6 +34,7 @@ impl InputState {
| Shape::Line { thick, .. }
| Shape::Rect { thick, .. }
| Shape::Ellipse { thick, .. }
| Shape::Polygon { thick, .. }
| Shape::Arrow { thick, .. }
| Shape::BlurRect {
strength: thick, ..
+5 -1
View File
@@ -71,6 +71,7 @@ pub(super) fn shape_color(shape: &Shape) -> Option<Color> {
| Shape::Line { color, .. }
| Shape::Rect { color, .. }
| Shape::Ellipse { color, .. }
| Shape::Polygon { color, .. }
| Shape::Arrow { color, .. }
| Shape::Text { color, .. }
| Shape::StepMarker { color, .. } => Some(*color),
@@ -86,6 +87,7 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option<f64> {
| Shape::Line { thick, .. }
| Shape::Rect { thick, .. }
| Shape::Ellipse { thick, .. }
| Shape::Polygon { thick, .. }
| Shape::Arrow { thick, .. }
| Shape::BlurRect {
strength: thick, ..
@@ -97,7 +99,9 @@ pub(super) fn shape_thickness(shape: &Shape) -> Option<f64> {
pub(super) fn shape_fill(shape: &Shape) -> Option<bool> {
match shape {
Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } => Some(*fill),
Shape::Rect { fill, .. } | Shape::Ellipse { fill, .. } | Shape::Polygon { fill, .. } => {
Some(*fill)
}
_ => None,
}
}
@@ -197,6 +197,23 @@ impl InputState {
label: label.clone(),
}
}
Shape::Polygon {
kind,
points,
fill,
color,
thick,
} => {
let scaled_points =
Self::scale_points(points, anchor_x, anchor_y, scale_x, scale_y);
Shape::Polygon {
kind: *kind,
points: scaled_points,
fill: *fill,
color: *color,
thick: *thick,
}
}
Shape::BlurRect {
x,
y,
@@ -65,6 +65,12 @@ impl InputState {
*cx += dx;
*cy += dy;
}
Shape::Polygon { points, .. } => {
for point in points {
point.0 += dx;
point.1 += dy;
}
}
Shape::Arrow { x1, y1, x2, y2, .. } => {
*x1 += dx;
*x2 += dx;
+8
View File
@@ -36,6 +36,7 @@ impl InputState {
|| matches!(
self.state,
DrawingState::Drawing { .. }
| DrawingState::BuildingPolygon { .. }
| DrawingState::PendingTextClick { .. }
| DrawingState::MovingSelection { .. }
| DrawingState::Selecting { .. }
@@ -86,6 +87,13 @@ mod tests {
tool: Tool::Pen,
shape_id: 1,
},
DrawingState::BuildingPolygon {
points: vec![(10, 20)],
preview: None,
fill: false,
color: BLACK,
thick: 2.0,
},
DrawingState::MovingSelection {
last_x: 10,
last_y: 20,
+2 -1
View File
@@ -407,7 +407,8 @@ fn estimate_shape_storage(shape: &Shape) -> CloneStorageEstimate {
match shape {
Shape::Freehand { points, .. }
| Shape::MarkerStroke { points, .. }
| Shape::EraserStroke { points, .. } => {
| Shape::EraserStroke { points, .. }
| Shape::Polygon { points, .. } => {
estimate.add_non_image_shape(
NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES
.saturating_add(usize_to_u64(points.len()).saturating_mul(POINT_JSON_BYTES)),
@@ -31,8 +31,10 @@ impl InputState {
None => return false,
};
if matches!(self.state, DrawingState::TextInput { .. }) {
self.cancel_text_input();
match self.state {
DrawingState::TextInput { .. } => self.cancel_text_input(),
DrawingState::BuildingPolygon { .. } => self.cancel_active_interaction(),
_ => {}
}
let legacy_step_marker_preset =
@@ -109,6 +111,9 @@ impl InputState {
self.needs_redraw = true;
self.mark_session_dirty();
}
if let Some(polygon_sides) = preset.polygon_sides {
let _ = self.set_polygon_sides(polygon_sides);
}
if let Some(show_status_bar) = preset.show_status_bar
&& !(self.presenter_mode && self.presenter_mode_config.hide_status_bar)
&& self.show_status_bar != show_status_bar
@@ -285,6 +290,7 @@ impl InputState {
arrow_length: Some(self.arrow_length),
arrow_angle: Some(self.arrow_angle),
arrow_head_at_end: Some(self.arrow_head_at_end),
polygon_sides: Some(self.polygon_sides),
show_status_bar: Some(self.show_status_bar),
drag_tools: Some(self.drag_tool_bindings.to_config()),
}
+23 -3
View File
@@ -1,7 +1,7 @@
use super::super::base::{
DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, UiToastKind,
};
use crate::draw::{Color, FontDescriptor};
use crate::draw::{Color, FontDescriptor, clamp_regular_sides};
use crate::input::{
DragBinding, MouseButton,
modifiers::DragToolBindings,
@@ -154,8 +154,7 @@ impl InputState {
self.state,
DrawingState::Idle | DrawingState::TextInput { .. }
) {
self.state = DrawingState::Idle;
self.end_pointer_drag();
self.cancel_active_interaction();
}
self.sync_current_settings_from_active_tool();
@@ -363,4 +362,25 @@ impl InputState {
self.mark_session_dirty();
true
}
pub fn set_polygon_sides(&mut self, sides: u8) -> bool {
let clamped = clamp_regular_sides(sides);
if self.polygon_sides == clamped {
return false;
}
self.polygon_sides = clamped;
self.dirty_tracker.mark_full();
self.needs_redraw = true;
self.mark_session_dirty();
true
}
pub fn nudge_polygon_sides(&mut self, delta: i8) -> bool {
let next = if delta.is_negative() {
self.polygon_sides.saturating_sub(delta.unsigned_abs())
} else {
self.polygon_sides.saturating_add(delta as u8)
};
self.set_polygon_sides(next)
}
}
@@ -145,6 +145,17 @@ impl InputState {
self.last_canvas_pointer_position = self.canvas_coords_for_screen(x, y);
}
pub(crate) fn record_first_stroke_done_for_onboarding(&mut self) {
if self.pending_onboarding_usage.first_stroke_done {
return;
}
// First-run onboarding card can live outside the stroke bounds. Force a
// full repaint so the step transition appears immediately.
self.dirty_tracker.mark_full();
self.pending_onboarding_usage.first_stroke_done = true;
}
/// Updates the undo stack limit for subsequent actions.
pub fn set_undo_stack_limit(&mut self, limit: usize) {
self.undo_stack_limit = limit.max(1);
@@ -196,6 +207,13 @@ impl InputState {
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
DrawingState::BuildingPolygon { .. } => {
self.clear_provisional_dirty();
self.last_provisional_bounds = None;
self.last_polygon_click = None;
self.state = DrawingState::Idle;
self.needs_redraw = true;
}
DrawingState::MovingSelection { snapshots, .. } => {
self.restore_selection_from_snapshots(snapshots.clone());
self.state = DrawingState::Idle;
+5
View File
@@ -45,6 +45,11 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::SelectLineTool
| Action::SelectRectTool
| Action::SelectEllipseTool
| Action::SelectTriangleTool
| Action::SelectParallelogramTool
| Action::SelectRhombusTool
| Action::SelectRegularPolygonTool
| Action::SelectFreeformPolygonTool
| Action::SelectArrowTool
| Action::SelectBlurTool
| Action::SelectHighlightTool
+1
View File
@@ -5,6 +5,7 @@ pub(crate) fn active_interaction_kind(state: &InputState) -> Option<ActiveIntera
match state.state {
DrawingState::Idle => None,
DrawingState::Drawing { .. } => Some(ActiveInteractionKind::Drawing),
DrawingState::BuildingPolygon { .. } => Some(ActiveInteractionKind::BuildingPolygon),
DrawingState::TextInput { .. } => Some(ActiveInteractionKind::TextInput),
DrawingState::PendingTextClick { .. } => Some(ActiveInteractionKind::PendingTextClick),
DrawingState::MovingSelection { .. } => Some(ActiveInteractionKind::MovingSelection),
@@ -116,6 +116,15 @@ pub(crate) fn handle_active_motion(
));
}
if let DrawingState::BuildingPolygon { preview, .. } = &mut state.state {
*preview = Some((canvas.x(), canvas.y()));
state.update_provisional_dirty(canvas.x(), canvas.y());
state.needs_redraw = true;
return Some(RoutingOutcome::Continued(
ActiveInteractionKind::BuildingPolygon,
));
}
None
}
@@ -168,7 +177,9 @@ pub(crate) fn releasable_active_kind(state: &InputState) -> Option<ActiveInterac
DrawingState::PendingTextClick { .. } => Some(ActiveInteractionKind::PendingTextClick),
DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText),
DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection),
DrawingState::Idle | DrawingState::TextInput { .. } => None,
DrawingState::Idle
| DrawingState::TextInput { .. }
| DrawingState::BuildingPolygon { .. } => None,
}
}
@@ -137,6 +137,37 @@ pub(crate) fn handle_text_input_key(state: &mut InputState, key: Key) -> Option<
None
}
pub(crate) fn handle_building_polygon_key(
state: &mut InputState,
key: Key,
) -> Option<RoutingOutcome> {
if !matches!(state.state, DrawingState::BuildingPolygon { .. }) {
return None;
}
match key {
Key::Return => {
state.finish_building_polygon();
Some(RoutingOutcome::Finished(
ActiveInteractionKind::BuildingPolygon,
))
}
Key::Escape => {
state.cancel_active_interaction();
Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(
ActiveInteractionKind::BuildingPolygon,
)))
}
Key::Backspace => {
state.pop_building_polygon_point();
Some(RoutingOutcome::Continued(
ActiveInteractionKind::BuildingPolygon,
))
}
_ => None,
}
}
pub(crate) fn handle_drawing_escape_cancel_key(
state: &mut InputState,
key: Key,
+12 -11
View File
@@ -9,18 +9,19 @@ pub(crate) use active_motion::{
release_button_matches_active_drag,
};
pub(crate) use keyboard::{
action_for_key_binding, handle_board_picker_key, handle_color_picker_key,
handle_command_palette_key, handle_context_menu_key, handle_drawing_escape_cancel_key,
handle_global_modifier_key, handle_help_overlay_key, handle_idle_selection_cancel_key,
handle_pending_delete_cancel_key, handle_properties_panel_key, handle_radial_menu_key,
handle_return_edit_selected_text_key, handle_text_input_key, handle_tour_key,
action_for_key_binding, handle_board_picker_key, handle_building_polygon_key,
handle_color_picker_key, handle_command_palette_key, handle_context_menu_key,
handle_drawing_escape_cancel_key, handle_global_modifier_key, handle_help_overlay_key,
handle_idle_selection_cancel_key, handle_pending_delete_cancel_key,
handle_properties_panel_key, handle_radial_menu_key, handle_return_edit_selected_text_key,
handle_text_input_key, handle_tour_key,
};
pub(crate) use pointer::{
close_properties_panel_before_tool_routing, finish_pointer_interaction,
handle_board_picker_motion, handle_board_picker_press, handle_color_picker_motion,
handle_color_picker_press, handle_context_menu_motion, handle_left_context_menu_press,
handle_middle_press, handle_properties_panel_motion, handle_properties_panel_press,
handle_radial_menu_motion, handle_radial_menu_press, handle_radial_menu_release,
handle_release_overlays, handle_right_press, handle_tool_button_press,
handle_unbound_left_press, update_pointer_positions,
handle_board_picker_motion, handle_board_picker_press, handle_building_polygon_non_left_press,
handle_color_picker_motion, handle_color_picker_press, handle_context_menu_motion,
handle_left_context_menu_press, handle_middle_press, handle_properties_panel_motion,
handle_properties_panel_press, handle_radial_menu_motion, handle_radial_menu_press,
handle_radial_menu_release, handle_release_overlays, handle_right_press,
handle_tool_button_press, handle_unbound_left_press, update_pointer_positions,
};
@@ -27,6 +27,30 @@ pub(crate) fn handle_radial_menu_press(
.then_some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu))
}
pub(crate) fn handle_building_polygon_non_left_press(
state: &mut InputState,
button: MouseButton,
points: PointerPoints,
) -> Option<RoutingOutcome> {
if !matches!(state.state, DrawingState::BuildingPolygon { .. }) {
return None;
}
let screen = points.screen();
let canvas = points.canvas();
state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y());
match button {
MouseButton::Right => {
state.cancel_active_interaction();
Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(
ActiveInteractionKind::BuildingPolygon,
)))
}
MouseButton::Middle => Some(RoutingOutcome::Consumed(ConsumedBy::ToolButton)),
MouseButton::Left => None,
}
}
pub(crate) fn handle_color_picker_press(
state: &mut InputState,
button: MouseButton,
@@ -128,6 +152,10 @@ pub(crate) fn handle_unbound_left_press(
state.needs_redraw = true;
RoutingOutcome::Consumed(ConsumedBy::TextInput)
}
DrawingState::BuildingPolygon { .. } => {
state.handle_building_polygon_left_click(canvas.x(), canvas.y());
RoutingOutcome::Continued(ActiveInteractionKind::BuildingPolygon)
}
DrawingState::Drawing { .. }
| DrawingState::MovingSelection { .. }
| DrawingState::Selecting { .. }
+3
View File
@@ -41,6 +41,9 @@ pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcom
if let Some(outcome) = adapters::handle_text_input_key(state, key) {
return outcome;
}
if let Some(outcome) = adapters::handle_building_polygon_key(state, key) {
return outcome;
}
if let Some(outcome) = adapters::handle_drawing_escape_cancel_key(state, key) {
return outcome;
}
+1
View File
@@ -29,6 +29,7 @@ pub(crate) enum ConsumedBy {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActiveInteractionKind {
Drawing,
BuildingPolygon,
TextInput,
PendingTextClick,
MovingSelection,
+5
View File
@@ -6,6 +6,11 @@ use crate::input::state::InputState;
pub(crate) fn route_pointer_press(state: &mut InputState, event: PointerPress) -> RoutingOutcome {
let points = event.points();
if let Some(outcome) =
adapters::handle_building_polygon_non_left_press(state, event.button(), points)
{
return outcome;
}
if let Some(outcome) = adapters::handle_radial_menu_press(state, event.button(), points) {
return outcome;
}
+153 -1
View File
@@ -1,14 +1,18 @@
use crate::draw::Shape;
use crate::draw::frame::ShapeSnapshot;
use crate::draw::frame::{ShapeSnapshot, UndoAction};
use crate::draw::shape::{PolygonKind, has_minimum_distinct_points};
use crate::input::tool::ToolPressBehavior;
use crate::input::{DragTool, Tool, events::MouseButton};
use std::sync::Arc;
use std::time::Instant;
use super::super::core::MenuCommand;
use super::super::core::PolygonClickState;
use super::super::{
ContextMenuKind, DrawingState, InputState,
interaction::{CanvasPoint, PointerPoints, PointerPress, ScreenPoint, route_pointer_press},
};
use super::{TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS};
#[derive(Clone, Copy)]
struct PressCoords {
@@ -203,7 +207,11 @@ impl InputState {
self.update_text_preview_dirty();
self.needs_redraw = true;
}
DrawingState::BuildingPolygon { .. } if button == MouseButton::Left => {
self.handle_building_polygon_left_click(coords.canvas_x, coords.canvas_y);
}
DrawingState::TextInput { .. }
| DrawingState::BuildingPolygon { .. }
| DrawingState::Drawing { .. }
| DrawingState::MovingSelection { .. }
| DrawingState::Selecting { .. }
@@ -350,6 +358,9 @@ impl InputState {
match tool.press_behavior() {
ToolPressBehavior::Selection | ToolPressBehavior::HighlightNoop => {}
ToolPressBehavior::StartFreeformPolygon => {
self.start_building_polygon(x, y);
}
ToolPressBehavior::StartDrawing {
request_blur_capture,
} => {
@@ -373,6 +384,147 @@ impl InputState {
}
}
pub(crate) fn start_building_polygon(&mut self, x: i32, y: i32) {
self.sync_current_settings_for_tool(Tool::FreeformPolygon);
let color = self.color_for_tool(Tool::FreeformPolygon);
let thick = self.thickness_for_tool(Tool::FreeformPolygon);
self.clear_selection();
self.last_polygon_click = Some(PolygonClickState {
x,
y,
at: Instant::now(),
});
self.state = DrawingState::BuildingPolygon {
points: vec![(x, y)],
preview: None,
fill: self.fill_enabled,
color,
thick,
};
self.last_provisional_bounds = None;
self.update_provisional_dirty(x, y);
self.set_ui_toast(
super::super::UiToastKind::Info,
"Click points. Enter/double-click to finish. Backspace undo. Esc cancel.",
);
self.needs_redraw = true;
}
fn should_finish_building_polygon_on_click(&self, x: i32, y: i32) -> bool {
let Some(last) = self.last_polygon_click else {
return false;
};
if Instant::now().duration_since(last.at).as_millis() > TEXT_DOUBLE_CLICK_MS as u128 {
return false;
}
if (x - last.x).abs() > TEXT_DOUBLE_CLICK_DISTANCE
|| (y - last.y).abs() > TEXT_DOUBLE_CLICK_DISTANCE
{
return false;
}
let DrawingState::BuildingPolygon { points, .. } = &self.state else {
return false;
};
has_minimum_distinct_points(points)
}
pub(crate) fn handle_building_polygon_left_click(&mut self, x: i32, y: i32) {
if self.should_finish_building_polygon_on_click(x, y) {
self.finish_building_polygon();
} else {
self.append_building_polygon_point(x, y);
}
}
pub(crate) fn append_building_polygon_point(&mut self, x: i32, y: i32) {
let DrawingState::BuildingPolygon {
points, preview, ..
} = &mut self.state
else {
return;
};
points.push((x, y));
*preview = None;
self.last_polygon_click = Some(PolygonClickState {
x,
y,
at: Instant::now(),
});
self.update_provisional_dirty(x, y);
self.needs_redraw = true;
}
pub(crate) fn pop_building_polygon_point(&mut self) {
let DrawingState::BuildingPolygon { points, .. } = &mut self.state else {
return;
};
let _ = points.pop();
if points.is_empty() {
self.clear_provisional_dirty();
self.last_polygon_click = None;
self.state = DrawingState::Idle;
} else {
let (x, y) = self.canvas_pointer_position();
self.last_polygon_click = None;
self.update_provisional_dirty(x, y);
}
self.needs_redraw = true;
}
pub(crate) fn finish_building_polygon(&mut self) {
let state = std::mem::replace(&mut self.state, DrawingState::Idle);
let DrawingState::BuildingPolygon {
points,
fill,
color,
thick,
..
} = state
else {
self.state = state;
return;
};
self.clear_provisional_dirty();
self.last_polygon_click = None;
if !has_minimum_distinct_points(&points) {
self.needs_redraw = true;
return;
}
let shape = Shape::Polygon {
kind: PolygonKind::Freeform,
points,
fill,
color,
thick,
};
let bounds = shape.bounding_box();
let addition = {
let frame = self.boards.active_frame_mut();
frame
.try_add_shape_with_id(shape, self.max_shapes_per_frame)
.and_then(|new_id| {
let index = frame.find_index(new_id)?;
let snapshot = frame.shape(new_id)?.clone();
frame.push_undo_action(
UndoAction::Create {
shapes: vec![(index, snapshot.clone())],
},
self.undo_stack_limit,
);
Some((new_id, snapshot))
})
};
if let Some((new_id, _snapshot)) = addition {
self.invalidate_hit_cache_for(new_id);
self.dirty_tracker.mark_optional_rect(bounds);
self.mark_session_dirty();
self.record_first_stroke_done_for_onboarding();
}
self.needs_redraw = true;
}
pub(in crate::input::state) fn handle_context_menu_press(
&mut self,
screen_x: i32,
+36 -28
View File
@@ -1,7 +1,7 @@
use log::warn;
use crate::draw::frame::UndoAction;
use crate::input::tool::{FinishedToolStroke, ToolStrokeSnapshot};
use crate::input::tool::{FinishedToolStroke, PolygonStrokeSnapshot, ToolStrokeSnapshot};
use crate::input::{InputState, Tool};
pub(super) struct DrawingRelease {
@@ -14,28 +14,42 @@ pub(super) struct DrawingRelease {
pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: DrawingRelease) {
let drawing_color = state.active_drag_color_or_tool(tool);
let drawing_thickness = state.thickness_for_tool(tool);
let snapshot = ToolStrokeSnapshot {
tool,
start: release.start,
end: release.end,
points: release.points,
point_thicknesses: release.point_thicknesses,
color: drawing_color,
size: drawing_thickness,
marker_opacity: state.marker_opacity,
fill_enabled: state.fill_enabled,
arrow_length: state.arrow_length,
arrow_angle: state.arrow_angle,
arrow_head_at_end: state.arrow_head_at_end,
arrow_label: state.next_arrow_label(),
step_marker_label: state.next_step_marker_label(),
eraser_mode: state.eraser_mode,
eraser_size: state.eraser_size,
eraser_kind: state.eraser_kind,
pressure_variation_threshold: state.pressure_variation_threshold,
let finished = if tool.polygon_template().is_some() {
let snapshot = PolygonStrokeSnapshot {
tool,
start: release.start,
end: release.end,
color: drawing_color,
size: drawing_thickness,
fill_enabled: state.fill_enabled,
regular_sides: state.polygon_sides,
};
tool.finish_polygon_stroke(snapshot)
} else {
let snapshot = ToolStrokeSnapshot {
tool,
start: release.start,
end: release.end,
points: release.points,
point_thicknesses: release.point_thicknesses,
color: drawing_color,
size: drawing_thickness,
marker_opacity: state.marker_opacity,
fill_enabled: state.fill_enabled,
arrow_length: state.arrow_length,
arrow_angle: state.arrow_angle,
arrow_head_at_end: state.arrow_head_at_end,
arrow_label: state.next_arrow_label(),
step_marker_label: state.next_step_marker_label(),
eraser_mode: state.eraser_mode,
eraser_size: state.eraser_size,
eraser_kind: state.eraser_kind,
pressure_variation_threshold: state.pressure_variation_threshold,
};
tool.finish_stroke(snapshot)
};
let (shape, usage) = match tool.finish_stroke(snapshot) {
let (shape, usage) = match finished {
FinishedToolStroke::Shape { shape, usage } => (shape, usage),
FinishedToolStroke::EraseStroke { path } => {
state.clear_provisional_dirty();
@@ -88,13 +102,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin
state.clear_selection();
state.needs_redraw = true;
state.mark_session_dirty();
if !state.pending_onboarding_usage.first_stroke_done {
// First-run onboarding card can live outside the stroke bounds.
// Force a full repaint when first stroke usage is recorded so the
// step transition appears immediately.
state.dirty_tracker.mark_full();
state.pending_onboarding_usage.first_stroke_done = true;
}
state.record_first_stroke_done_for_onboarding();
if usage.bump_arrow_label {
state.bump_arrow_label();
}
+31 -2
View File
@@ -1,9 +1,11 @@
use crate::draw::render::render_freehand_pressure_borrowed;
use crate::draw::render::{render_freehand_pressure_borrowed, render_polygon_preview};
use crate::draw::{
Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape,
};
use crate::input::Tool;
use crate::input::tool::{ProvisionalToolSnapshot, ProvisionalToolStroke};
use crate::input::tool::{
PolygonProvisionalSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke,
};
use super::{DrawingState, InputState};
@@ -24,6 +26,19 @@ impl InputState {
return ProvisionalToolStroke::None;
};
if tool.polygon_template().is_some() {
let snapshot = PolygonProvisionalSnapshot {
tool: *tool,
start: (*start_x, *start_y),
current: (current_x, current_y),
color: self.active_drag_color_or_current(),
size: self.thickness_for_tool(*tool),
fill_enabled: self.fill_enabled,
regular_sides: self.polygon_sides,
};
return tool.provisional_polygon_stroke(snapshot);
}
let snapshot = ProvisionalToolSnapshot {
tool: *tool,
start: (*start_x, *start_y),
@@ -162,6 +177,20 @@ impl InputState {
let _ = ctx.restore();
true
}
DrawingState::BuildingPolygon {
points,
preview,
fill,
color,
thick,
} => {
let mut preview_points = points.clone();
if let Some(point) = preview.or(Some((current_x, current_y))) {
preview_points.push(point);
}
render_polygon_preview(ctx, &preview_points, *fill, *color, *thick);
true
}
_ => false,
}
}
+3
View File
@@ -31,6 +31,7 @@ fn apply_preset_updates_tool_and_settings() {
arrow_length: Some(25.0),
arrow_angle: Some(45.0),
arrow_head_at_end: Some(true),
polygon_sides: Some(8),
show_status_bar: Some(false),
drag_tools: None,
});
@@ -49,6 +50,7 @@ fn apply_preset_updates_tool_and_settings() {
assert_eq!(state.arrow_length, 25.0);
assert_eq!(state.arrow_angle, 45.0);
assert!(state.arrow_head_at_end);
assert_eq!(state.polygon_sides, 8);
assert_eq!(state.eraser_kind, EraserKind::Rect);
assert_eq!(state.eraser_mode, EraserMode::Stroke);
assert!(!state.show_status_bar);
@@ -81,6 +83,7 @@ fn apply_preset_merges_partial_left_drag_tool_bindings() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: Some(MouseDragToolsConfig::from_buttons(
left,
+172
View File
@@ -65,6 +65,178 @@ fn mouse_drag_creates_shapes_for_each_tool() {
assert_eq!(state.boards.active_frame().shapes.len(), 5);
}
#[test]
fn regular_polygon_drag_stores_concrete_points_and_side_metadata() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::RegularPolygon)));
assert!(state.set_polygon_sides(7));
state.on_mouse_press(MouseButton::Left, 0, 0);
state.on_mouse_release(MouseButton::Left, 100, 100);
let shape = &state.boards.active_frame().shapes[0].shape;
match shape {
Shape::Polygon {
kind: crate::draw::PolygonKind::Regular { sides },
points,
..
} => {
assert_eq!(*sides, 7);
assert_eq!(points.len(), 7);
}
other => panic!("expected regular polygon, got {other:?}"),
}
assert!(state.set_polygon_sides(3));
match &state.boards.active_frame().shapes[0].shape {
Shape::Polygon {
kind: crate::draw::PolygonKind::Regular { sides },
points,
..
} => {
assert_eq!(*sides, 7);
assert_eq!(points.len(), 7);
}
other => panic!("expected regular polygon, got {other:?}"),
}
}
#[test]
fn invalid_drag_polygon_tools_do_not_commit_ghost_shapes() {
for (tool, start, end) in [
(Tool::Triangle, (10, 10), (10, 10)),
(Tool::RegularPolygon, (10, 10), (10, 10)),
(Tool::Parallelogram, (0, 0), (1, 10)),
] {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(tool)));
state.on_mouse_press(MouseButton::Left, start.0, start.1);
state.on_mouse_release(MouseButton::Left, end.0, end.1);
assert!(
state.boards.active_frame().shapes.is_empty(),
"{tool:?} should not commit an invalid invisible polygon"
);
}
}
#[test]
fn alt_click_selects_filled_polygon_interior() {
let mut state = create_test_input_state();
let shape_id = state.boards.active_frame_mut().add_shape(Shape::Polygon {
kind: crate::draw::PolygonKind::Triangle,
points: vec![(10, 10), (40, 10), (25, 40)],
fill: true,
color: state.current_color,
thick: state.current_thickness,
});
state.modifiers.alt = true;
state.on_mouse_press(MouseButton::Left, 25, 22);
assert_eq!(state.selected_shape_ids(), &[shape_id]);
}
#[test]
fn freeform_polygon_double_click_finishes_without_duplicate_vertex() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::FreeformPolygon)));
state.on_mouse_press(MouseButton::Left, 0, 0);
state.on_mouse_press(MouseButton::Left, 20, 0);
state.on_mouse_press(MouseButton::Left, 20, 20);
state.on_mouse_press(MouseButton::Left, 20, 20);
assert!(matches!(state.state, DrawingState::Idle));
match &state.boards.active_frame().shapes[0].shape {
Shape::Polygon {
kind: crate::draw::PolygonKind::Freeform,
points,
..
} => assert_eq!(points, &vec![(0, 0), (20, 0), (20, 20)]),
other => panic!("expected freeform polygon, got {other:?}"),
}
}
#[test]
fn freeform_polygon_backspace_does_not_prime_double_click_commit() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::FreeformPolygon)));
state.on_mouse_press(MouseButton::Left, 0, 0);
state.on_mouse_press(MouseButton::Left, 20, 0);
state.on_mouse_press(MouseButton::Left, 20, 20);
state.on_mouse_press(MouseButton::Left, 0, 20);
state.pop_building_polygon_point();
state.on_mouse_press(MouseButton::Left, 0, 20);
assert!(state.boards.active_frame().shapes.is_empty());
match &state.state {
DrawingState::BuildingPolygon { points, .. } => {
assert_eq!(points, &vec![(0, 0), (20, 0), (20, 20), (0, 20)]);
}
other => panic!("expected polygon still building, got {other:?}"),
}
}
#[test]
fn freeform_polygon_commit_records_first_stroke_onboarding() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::FreeformPolygon)));
state.on_mouse_press(MouseButton::Left, 0, 0);
state.on_mouse_press(MouseButton::Left, 20, 0);
state.on_mouse_press(MouseButton::Left, 20, 20);
state.finish_building_polygon();
assert!(state.pending_onboarding_usage.first_stroke_done);
}
#[test]
fn freeform_polygon_preview_dirty_has_antialias_padding() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::FreeformPolygon)));
state.on_mouse_press(MouseButton::Left, 10, 10);
state.on_mouse_motion(20, 20);
let DrawingState::BuildingPolygon { thick, .. } = state.state else {
panic!("expected polygon building state");
};
let base = crate::draw::shape::bounding_box_for_points(&[(10, 10), (20, 20)], thick)
.expect("preview should have bounds");
assert_eq!(
state.last_provisional_bounds,
base.inflated(2),
"building polygon damage should be padded to clear antialias leftovers"
);
}
#[test]
fn freeform_polygon_freezes_style_on_first_click() {
let mut state = create_test_input_state();
assert!(state.set_tool_override(Some(Tool::FreeformPolygon)));
let original = state.current_color;
let changed = crate::draw::Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
};
state.on_mouse_press(MouseButton::Left, 0, 0);
assert!(state.set_color(changed));
state.on_mouse_press(MouseButton::Left, 20, 0);
state.on_mouse_press(MouseButton::Left, 20, 20);
state.finish_building_polygon();
match &state.boards.active_frame().shapes[0].shape {
Shape::Polygon { color, .. } => assert_eq!(*color, original),
other => panic!("expected freeform polygon, got {other:?}"),
}
}
#[test]
fn custom_drag_bindings_remap_default_and_modifier_tools() {
let mut state = create_test_input_state();
+1
View File
@@ -37,6 +37,7 @@ fn presenter_mode_blocks_preset_status_bar_toggle() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: Some(true),
drag_tools: None,
};
+47 -1
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::config::{PresenterToolBehavior, PresetToolStatesConfig, ToolPresetConfig};
use crate::input::{DragBinding, DragToolBindings, PerToolDrawingSettings};
use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarSnapshot};
use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot};
#[test]
fn set_tool_override_clears_active_preset_and_resets_drawing_state() {
@@ -287,6 +287,46 @@ fn toolbar_context_matches_tool_profiles_for_each_tool() {
}
}
#[test]
fn toolbar_context_exposes_polygon_shape_controls() {
let mut state = create_test_input_state();
for tool in [
Tool::Triangle,
Tool::Parallelogram,
Tool::Rhombus,
Tool::RegularPolygon,
Tool::FreeformPolygon,
] {
assert!(state.set_tool_override(Some(tool)));
let snapshot = ToolbarSnapshot::from_input(&state);
let context = ToolContext::from_snapshot(&snapshot);
assert_eq!(context.tool_options_kind, ToolOptionsKind::Shape);
assert!(context.show_fill_toggle);
assert_eq!(
context.show_polygon_sides_control,
tool == Tool::RegularPolygon,
"{tool:?} sides control"
);
}
}
#[test]
fn polygon_side_controls_clamp_and_mark_session_dirty() {
let mut state = create_test_input_state();
state.clear_session_dirty();
assert!(state.apply_toolbar_event(ToolbarEvent::SetPolygonSides(2)));
assert_eq!(state.polygon_sides, 3);
assert!(state.is_session_dirty());
state.clear_session_dirty();
assert!(state.apply_toolbar_event(ToolbarEvent::NudgePolygonSides(99)));
assert_eq!(state.polygon_sides, 12);
assert!(state.is_session_dirty());
}
#[test]
fn nudge_thickness_for_active_tool_clamps_pen_thickness() {
let mut state = create_test_input_state();
@@ -708,6 +748,7 @@ fn apply_full_preset_restores_all_tool_settings() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
@@ -790,6 +831,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
@@ -808,6 +850,7 @@ fn toolbar_preset_preview_uses_nested_profile_for_active_preset_tool() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
@@ -854,6 +897,7 @@ fn legacy_preset_changes_only_selected_tool_settings() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
@@ -891,6 +935,7 @@ fn legacy_step_marker_preset_uses_font_derived_size() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
@@ -927,6 +972,7 @@ fn full_step_marker_preset_uses_captured_profile_size() {
arrow_length: None,
arrow_angle: None,
arrow_head_at_end: None,
polygon_sides: None,
show_status_bar: None,
drag_tools: None,
});
+130 -20
View File
@@ -1,4 +1,5 @@
use crate::config::Action;
use crate::draw::shape::PolygonTemplate;
use super::{DragTool, Tool, ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource};
@@ -9,7 +10,7 @@ pub(crate) struct ToolDescriptor {
pub(crate) short_label: &'static str,
pub(crate) display_label: &'static str,
pub(crate) action: Option<Action>,
pub(crate) drag_tool: DragTool,
pub(crate) drag_tool: Option<DragTool>,
pub(crate) profile: ToolProfile,
pub(crate) press: ToolPressBehavior,
pub(crate) motion: ToolMotionBehavior,
@@ -20,6 +21,7 @@ pub(crate) struct ToolDescriptor {
pub(crate) enum ToolPressBehavior {
Selection,
HighlightNoop,
StartFreeformPolygon,
StartDrawing { request_blur_capture: bool },
}
@@ -45,6 +47,7 @@ pub(crate) enum ToolDrawingBehavior {
Line,
Rect,
Ellipse,
Polygon(PolygonTemplate),
Arrow,
BlurRect,
StepMarker,
@@ -79,13 +82,13 @@ const fn profile(
}
}
const DESCRIPTORS: [ToolDescriptor; 11] = [
const DESCRIPTORS: [ToolDescriptor; 16] = [
ToolDescriptor {
tool: Tool::Select,
short_label: "Select",
display_label: "Selection Tool",
action: Some(Action::SelectSelectionTool),
drag_tool: DragTool::Select,
drag_tool: Some(DragTool::Select),
profile: profile(
ToolSettingsSlot::Pen,
ToolSizeSource::DrawingThickness,
@@ -102,7 +105,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Pen",
display_label: "Pen Tool",
action: Some(Action::SelectPenTool),
drag_tool: DragTool::Pen,
drag_tool: Some(DragTool::Pen),
profile: profile(
ToolSettingsSlot::Pen,
ToolSizeSource::DrawingThickness,
@@ -126,7 +129,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Line",
display_label: "Line Tool",
action: Some(Action::SelectLineTool),
drag_tool: DragTool::Line,
drag_tool: Some(DragTool::Line),
profile: profile(
ToolSettingsSlot::Line,
ToolSizeSource::DrawingThickness,
@@ -145,7 +148,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Rect",
display_label: "Rectangle Tool",
action: Some(Action::SelectRectTool),
drag_tool: DragTool::Rect,
drag_tool: Some(DragTool::Rect),
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
@@ -164,7 +167,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Circle",
display_label: "Ellipse Tool",
action: Some(Action::SelectEllipseTool),
drag_tool: DragTool::Ellipse,
drag_tool: Some(DragTool::Ellipse),
profile: profile(
ToolSettingsSlot::Ellipse,
ToolSizeSource::DrawingThickness,
@@ -178,12 +181,105 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::Ellipse,
},
ToolDescriptor {
tool: Tool::Triangle,
short_label: "Triangle",
display_label: "Triangle Tool",
action: Some(Action::SelectTriangleTool),
drag_tool: Some(DragTool::Triangle),
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
ToolControlGroup::Shape,
true,
"Thickness",
),
press: ToolPressBehavior::StartDrawing {
request_blur_capture: false,
},
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::Polygon(PolygonTemplate::Triangle),
},
ToolDescriptor {
tool: Tool::Parallelogram,
short_label: "Para",
display_label: "Parallelogram Tool",
action: Some(Action::SelectParallelogramTool),
drag_tool: Some(DragTool::Parallelogram),
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
ToolControlGroup::Shape,
true,
"Thickness",
),
press: ToolPressBehavior::StartDrawing {
request_blur_capture: false,
},
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::Polygon(PolygonTemplate::Parallelogram),
},
ToolDescriptor {
tool: Tool::Rhombus,
short_label: "Rhombus",
display_label: "Rhombus Tool",
action: Some(Action::SelectRhombusTool),
drag_tool: Some(DragTool::Rhombus),
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
ToolControlGroup::Shape,
true,
"Thickness",
),
press: ToolPressBehavior::StartDrawing {
request_blur_capture: false,
},
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::Polygon(PolygonTemplate::Rhombus),
},
ToolDescriptor {
tool: Tool::RegularPolygon,
short_label: "Polygon",
display_label: "Regular Polygon Tool",
action: Some(Action::SelectRegularPolygonTool),
drag_tool: Some(DragTool::RegularPolygon),
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
ToolControlGroup::Shape,
true,
"Thickness",
),
press: ToolPressBehavior::StartDrawing {
request_blur_capture: false,
},
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::Polygon(PolygonTemplate::Regular),
},
ToolDescriptor {
tool: Tool::FreeformPolygon,
short_label: "Freeform",
display_label: "Freeform Polygon Tool",
action: Some(Action::SelectFreeformPolygonTool),
drag_tool: None,
profile: profile(
ToolSettingsSlot::Rect,
ToolSizeSource::DrawingThickness,
ToolControlGroup::Shape,
true,
"Thickness",
),
press: ToolPressBehavior::StartFreeformPolygon,
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::None,
},
ToolDescriptor {
tool: Tool::Arrow,
short_label: "Arrow",
display_label: "Arrow Tool",
action: Some(Action::SelectArrowTool),
drag_tool: DragTool::Arrow,
drag_tool: Some(DragTool::Arrow),
profile: profile(
ToolSettingsSlot::Arrow,
ToolSizeSource::DrawingThickness,
@@ -202,7 +298,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Blur",
display_label: "Blur Tool",
action: Some(Action::SelectBlurTool),
drag_tool: DragTool::Blur,
drag_tool: Some(DragTool::Blur),
profile: profile(
ToolSettingsSlot::Blur,
ToolSizeSource::DrawingThickness,
@@ -221,7 +317,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Marker",
display_label: "Marker Tool",
action: Some(Action::SelectMarkerTool),
drag_tool: DragTool::Marker,
drag_tool: Some(DragTool::Marker),
profile: profile(
ToolSettingsSlot::Marker,
ToolSizeSource::DrawingThickness,
@@ -245,7 +341,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Highlight",
display_label: "Highlight Tool",
action: Some(Action::SelectHighlightTool),
drag_tool: DragTool::Highlight,
drag_tool: Some(DragTool::Highlight),
profile: profile(
ToolSettingsSlot::Pen,
ToolSizeSource::DrawingThickness,
@@ -262,7 +358,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Steps",
display_label: "Step Marker Tool",
action: Some(Action::SelectStepMarkerTool),
drag_tool: DragTool::StepMarker,
drag_tool: Some(DragTool::StepMarker),
profile: profile(
ToolSettingsSlot::StepMarker,
ToolSizeSource::DrawingThickness,
@@ -281,7 +377,7 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
short_label: "Eraser",
display_label: "Eraser Tool",
action: Some(Action::SelectEraserTool),
drag_tool: DragTool::Eraser,
drag_tool: Some(DragTool::Eraser),
profile: profile(
ToolSettingsSlot::Pen,
ToolSizeSource::EraserSize,
@@ -300,12 +396,17 @@ const DESCRIPTORS: [ToolDescriptor; 11] = [
];
impl Tool {
pub(crate) const ALL: [Self; 11] = [
pub(crate) const ALL: [Self; 16] = [
Self::Select,
Self::Pen,
Self::Line,
Self::Rect,
Self::Ellipse,
Self::Triangle,
Self::Parallelogram,
Self::Rhombus,
Self::RegularPolygon,
Self::FreeformPolygon,
Self::Arrow,
Self::Blur,
Self::Marker,
@@ -321,12 +422,17 @@ impl Tool {
Self::Line => &DESCRIPTORS[2],
Self::Rect => &DESCRIPTORS[3],
Self::Ellipse => &DESCRIPTORS[4],
Self::Arrow => &DESCRIPTORS[5],
Self::Blur => &DESCRIPTORS[6],
Self::Marker => &DESCRIPTORS[7],
Self::Highlight => &DESCRIPTORS[8],
Self::StepMarker => &DESCRIPTORS[9],
Self::Eraser => &DESCRIPTORS[10],
Self::Triangle => &DESCRIPTORS[5],
Self::Parallelogram => &DESCRIPTORS[6],
Self::Rhombus => &DESCRIPTORS[7],
Self::RegularPolygon => &DESCRIPTORS[8],
Self::FreeformPolygon => &DESCRIPTORS[9],
Self::Arrow => &DESCRIPTORS[10],
Self::Blur => &DESCRIPTORS[11],
Self::Marker => &DESCRIPTORS[12],
Self::Highlight => &DESCRIPTORS[13],
Self::StepMarker => &DESCRIPTORS[14],
Self::Eraser => &DESCRIPTORS[15],
}
}
@@ -338,6 +444,10 @@ impl Tool {
self.descriptor().action
}
pub(crate) fn drag_tool(self) -> Option<DragTool> {
self.descriptor().drag_tool
}
pub(crate) fn from_select_action(action: Action) -> Option<Self> {
Self::ALL
.iter()
+124 -9
View File
@@ -22,6 +22,14 @@ pub enum DragTool {
Rect,
/// Ellipse/circle outline.
Ellipse,
/// Triangle generated from drag bounds.
Triangle,
/// Parallelogram generated from drag bounds.
Parallelogram,
/// Rhombus/diamond generated from drag bounds.
Rhombus,
/// Regular polygon generated from drag bounds.
RegularPolygon,
/// Arrow with directional head.
Arrow,
/// Privacy blur rectangle.
@@ -37,17 +45,124 @@ pub enum DragTool {
}
impl DragTool {
pub fn from_tool(tool: Tool) -> Self {
tool.descriptor().drag_tool
pub fn from_tool(tool: Tool) -> Option<Self> {
let drag_tool = tool.drag_tool();
debug_assert_eq!(
drag_tool,
DragBindableTool::from_tool(tool).map(DragBindableTool::to_drag_tool)
);
drag_tool
}
pub fn as_tool(self) -> Option<Tool> {
if self == Self::Default {
return None;
}
Tool::ALL
.iter()
.copied()
.find(|tool| tool.descriptor().drag_tool == self)
DragBindableTool::from_drag_tool(self).map(DragBindableTool::to_tool)
}
}
/// Config-facing tool list for legacy flat drag fields.
///
/// It intentionally excludes `DragTool::Default` and non-drag selectable tools.
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DragBindableTool {
Select,
Pen,
Line,
Rect,
Ellipse,
Triangle,
Parallelogram,
Rhombus,
RegularPolygon,
Arrow,
Blur,
Marker,
Highlight,
StepMarker,
Eraser,
}
impl DragBindableTool {
pub fn to_drag_tool(self) -> DragTool {
match self {
Self::Select => DragTool::Select,
Self::Pen => DragTool::Pen,
Self::Line => DragTool::Line,
Self::Rect => DragTool::Rect,
Self::Ellipse => DragTool::Ellipse,
Self::Triangle => DragTool::Triangle,
Self::Parallelogram => DragTool::Parallelogram,
Self::Rhombus => DragTool::Rhombus,
Self::RegularPolygon => DragTool::RegularPolygon,
Self::Arrow => DragTool::Arrow,
Self::Blur => DragTool::Blur,
Self::Marker => DragTool::Marker,
Self::Highlight => DragTool::Highlight,
Self::StepMarker => DragTool::StepMarker,
Self::Eraser => DragTool::Eraser,
}
}
pub fn to_tool(self) -> Tool {
match self {
Self::Select => Tool::Select,
Self::Pen => Tool::Pen,
Self::Line => Tool::Line,
Self::Rect => Tool::Rect,
Self::Ellipse => Tool::Ellipse,
Self::Triangle => Tool::Triangle,
Self::Parallelogram => Tool::Parallelogram,
Self::Rhombus => Tool::Rhombus,
Self::RegularPolygon => Tool::RegularPolygon,
Self::Arrow => Tool::Arrow,
Self::Blur => Tool::Blur,
Self::Marker => Tool::Marker,
Self::Highlight => Tool::Highlight,
Self::StepMarker => Tool::StepMarker,
Self::Eraser => Tool::Eraser,
}
}
pub fn from_tool(tool: Tool) -> Option<Self> {
match tool {
Tool::Select => Some(Self::Select),
Tool::Pen => Some(Self::Pen),
Tool::Line => Some(Self::Line),
Tool::Rect => Some(Self::Rect),
Tool::Ellipse => Some(Self::Ellipse),
Tool::Triangle => Some(Self::Triangle),
Tool::Parallelogram => Some(Self::Parallelogram),
Tool::Rhombus => Some(Self::Rhombus),
Tool::RegularPolygon => Some(Self::RegularPolygon),
Tool::FreeformPolygon => None,
Tool::Arrow => Some(Self::Arrow),
Tool::Blur => Some(Self::Blur),
Tool::Marker => Some(Self::Marker),
Tool::Highlight => Some(Self::Highlight),
Tool::StepMarker => Some(Self::StepMarker),
Tool::Eraser => Some(Self::Eraser),
}
}
pub fn from_drag_tool(tool: DragTool) -> Option<Self> {
match tool {
DragTool::Default => None,
DragTool::Select => Some(Self::Select),
DragTool::Pen => Some(Self::Pen),
DragTool::Line => Some(Self::Line),
DragTool::Rect => Some(Self::Rect),
DragTool::Ellipse => Some(Self::Ellipse),
DragTool::Triangle => Some(Self::Triangle),
DragTool::Parallelogram => Some(Self::Parallelogram),
DragTool::Rhombus => Some(Self::Rhombus),
DragTool::RegularPolygon => Some(Self::RegularPolygon),
DragTool::Arrow => Some(Self::Arrow),
DragTool::Blur => Some(Self::Blur),
DragTool::Marker => Some(Self::Marker),
DragTool::Highlight => Some(Self::Highlight),
DragTool::StepMarker => Some(Self::StepMarker),
DragTool::Eraser => Some(Self::Eraser),
}
}
}
+121 -2
View File
@@ -1,10 +1,15 @@
use crate::draw::shape::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points};
use crate::draw::shape::{
PolygonTemplate, bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points,
generated_points, has_minimum_distinct_points,
};
use crate::draw::{ArrowLabel, BlurRectParams, Color, EraserBrush, EraserKind, Shape};
use crate::input::tool::{
EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior,
};
use crate::util::{self, Rect};
pub(crate) const PROVISIONAL_POLYGON_DAMAGE_PADDING: i32 = 2;
/// Immutable inputs needed to turn one completed drag into an app-level outcome.
pub(crate) struct ToolStrokeSnapshot {
pub(crate) tool: Tool,
@@ -27,6 +32,17 @@ pub(crate) struct ToolStrokeSnapshot {
pub(crate) pressure_variation_threshold: f64,
}
/// Immutable inputs needed to turn one completed polygon drag into a shape.
pub(crate) struct PolygonStrokeSnapshot {
pub(crate) tool: Tool,
pub(crate) start: (i32, i32),
pub(crate) end: (i32, i32),
pub(crate) color: Color,
pub(crate) size: f64,
pub(crate) fill_enabled: bool,
pub(crate) regular_sides: u8,
}
pub(crate) enum FinishedToolStroke {
Shape { shape: Shape, usage: ToolUsage },
EraseStroke { path: Vec<(i32, i32)> },
@@ -58,6 +74,17 @@ pub(crate) struct ProvisionalToolSnapshot<'a> {
pub(crate) step_marker_label: Option<crate::draw::StepMarkerLabel>,
}
/// Borrowed inputs needed to render the current live polygon preview.
pub(crate) struct PolygonProvisionalSnapshot {
pub(crate) tool: Tool,
pub(crate) start: (i32, i32),
pub(crate) current: (i32, i32),
pub(crate) color: Color,
pub(crate) size: f64,
pub(crate) fill_enabled: bool,
pub(crate) regular_sides: u8,
}
pub(crate) enum ProvisionalToolStroke<'a> {
BorrowedFreehand {
points: &'a [(i32, i32)],
@@ -130,6 +157,10 @@ impl Tool {
thick: snapshot.size,
}
}),
ToolDrawingBehavior::Polygon(_) => {
debug_assert!(false, "polygon strokes require PolygonStrokeSnapshot");
FinishedToolStroke::Noop
}
ToolDrawingBehavior::Arrow => {
let usage = ToolUsage {
bump_arrow_label: snapshot.arrow_label.is_some(),
@@ -256,6 +287,10 @@ impl Tool {
thick: snapshot.size,
})
}
ToolDrawingBehavior::Polygon(_) => {
debug_assert!(false, "polygon previews require PolygonProvisionalSnapshot");
ProvisionalToolStroke::None
}
ToolDrawingBehavior::Arrow => ProvisionalToolStroke::Shape(Shape::Arrow {
x1: snapshot.start.0,
y1: snapshot.start.1,
@@ -295,6 +330,83 @@ impl Tool {
},
}
}
pub(crate) fn polygon_template(self) -> Option<PolygonTemplate> {
match self.drawing_behavior() {
ToolDrawingBehavior::Polygon(template) => Some(template),
_ => None,
}
}
pub(crate) fn finish_polygon_stroke(
self,
snapshot: PolygonStrokeSnapshot,
) -> FinishedToolStroke {
debug_assert_eq!(self, snapshot.tool);
let Some(template) = self.polygon_template() else {
debug_assert!(false, "non-polygon tool cannot finish a polygon stroke");
return FinishedToolStroke::Noop;
};
finish_polygon(snapshot, ToolUsage::default(), template)
}
pub(crate) fn provisional_polygon_stroke(
self,
snapshot: PolygonProvisionalSnapshot,
) -> ProvisionalToolStroke<'static> {
debug_assert_eq!(self, snapshot.tool);
let Some(template) = self.polygon_template() else {
debug_assert!(false, "non-polygon tool cannot preview a polygon stroke");
return ProvisionalToolStroke::None;
};
provisional_polygon(snapshot, template)
}
}
fn finish_polygon(
snapshot: PolygonStrokeSnapshot,
usage: ToolUsage,
template: PolygonTemplate,
) -> FinishedToolStroke {
let points = generated_points(
template,
snapshot.start,
snapshot.end,
snapshot.regular_sides,
);
if !has_minimum_distinct_points(&points) {
return FinishedToolStroke::Noop;
}
FinishedToolStroke::Shape {
shape: Shape::Polygon {
kind: template.kind(snapshot.regular_sides),
points,
fill: snapshot.fill_enabled,
color: snapshot.color,
thick: snapshot.size,
},
usage,
}
}
fn provisional_polygon(
snapshot: PolygonProvisionalSnapshot,
template: PolygonTemplate,
) -> ProvisionalToolStroke<'static> {
let points = generated_points(
template,
snapshot.start,
snapshot.current,
snapshot.regular_sides,
);
ProvisionalToolStroke::Shape(Shape::Polygon {
kind: template.kind(snapshot.regular_sides),
points,
fill: snapshot.fill_enabled,
color: snapshot.color,
thick: snapshot.size,
})
}
impl<'a> ProvisionalToolStroke<'a> {
@@ -314,7 +426,14 @@ impl<'a> ProvisionalToolStroke<'a> {
bounding_box_for_points(points, inflated)
}
Self::EraserPreview { points, size } => bounding_box_for_eraser(points, *size),
Self::Shape(shape) => shape.bounding_box(),
Self::Shape(shape) => {
let bounds = shape.bounding_box();
if matches!(shape, Shape::Polygon { .. }) {
bounds.and_then(|rect| rect.inflated(PROVISIONAL_POLYGON_DAMAGE_PADDING))
} else {
bounds
}
}
Self::BlurReplayPreview(params) => {
bounding_box_for_blur(params.x, params.y, params.w, params.h)
}
+10
View File
@@ -18,6 +18,16 @@ pub enum Tool {
Rect,
/// Ellipse/circle outline - from center outward (Tab)
Ellipse,
/// Triangle generated from drag bounds.
Triangle,
/// Parallelogram generated from drag bounds.
Parallelogram,
/// Rhombus/diamond generated from drag bounds.
Rhombus,
/// Regular polygon generated from drag bounds.
RegularPolygon,
/// Click-to-add freeform polygon.
FreeformPolygon,
/// Arrow with directional head (Ctrl+Shift)
Arrow,
/// Privacy blur rectangle over the captured background
+3 -2
View File
@@ -19,14 +19,15 @@ pub(crate) use catalog::{
ToolDrawingBehavior, ToolMotionBehavior, ToolMotionSizeSource, ToolPathKind, ToolPressBehavior,
ToolPressureBehavior,
};
pub use drag::DragTool;
pub use drag::{DragBindableTool, DragTool};
#[expect(
unused_imports,
reason = "FinishedToolStroke exposes usage metadata to crate callers"
)]
pub(crate) use drawing::ToolUsage;
pub(crate) use drawing::{
FinishedToolStroke, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot,
FinishedToolStroke, PROVISIONAL_POLYGON_DAMAGE_PADDING, PolygonProvisionalSnapshot,
PolygonStrokeSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot,
};
pub use kind::Tool;
pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource};
+61 -3
View File
@@ -1,7 +1,7 @@
use super::drawing::marker_color_with_opacity;
use super::*;
use crate::config::Action;
use crate::draw::Color;
use crate::draw::{Color, Shape};
use std::collections::HashSet;
fn color(r: f64) -> Color {
@@ -49,6 +49,21 @@ fn tool_profile_describes_toolbar_control_groups() {
assert!(Tool::StepMarker.profile().show_step_counter());
}
#[test]
fn polygon_tools_use_shape_controls_and_rect_settings() {
for tool in [
Tool::Triangle,
Tool::Parallelogram,
Tool::Rhombus,
Tool::RegularPolygon,
Tool::FreeformPolygon,
] {
assert_eq!(tool.settings_slot(), ToolSettingsSlot::Rect);
assert_eq!(tool.profile().control_group, ToolControlGroup::Shape);
assert!(tool.profile().show_fill_toggle());
}
}
#[test]
fn per_tool_settings_read_and_write_through_catalog_slot() {
let mut settings = PerToolDrawingSettings::new(color(1.0), 4.0);
@@ -80,12 +95,30 @@ fn selectable_tools_expose_actions_from_catalog() {
fn drag_tools_round_trip_through_descriptor_table() {
for tool in Tool::ALL {
let drag_tool = DragTool::from_tool(tool);
assert_ne!(drag_tool, DragTool::Default);
assert_eq!(drag_tool.as_tool(), Some(tool));
if let Some(drag_tool) = drag_tool {
assert_ne!(drag_tool, DragTool::Default);
assert_eq!(drag_tool.as_tool(), Some(tool));
} else {
assert_eq!(tool, Tool::FreeformPolygon);
}
}
assert_eq!(DragTool::Default.as_tool(), None);
}
#[test]
fn drag_bindable_tool_list_excludes_freeform_polygon_and_default() {
assert_eq!(DragBindableTool::from_tool(Tool::FreeformPolygon), None);
assert_eq!(DragBindableTool::from_drag_tool(DragTool::Default), None);
assert_eq!(
DragBindableTool::from_tool(Tool::RegularPolygon),
Some(DragBindableTool::RegularPolygon)
);
assert_eq!(
DragTool::RegularPolygon.as_tool(),
Some(Tool::RegularPolygon)
);
}
#[test]
fn descriptor_exposes_press_motion_and_drawing_behavior() {
assert_eq!(Tool::Select.press_behavior(), ToolPressBehavior::Selection);
@@ -129,3 +162,28 @@ fn marker_opacity_helper_preserves_current_alpha_clamp() {
assert_eq!(marker_color_with_opacity(color(1.0), 0.0).a, 0.05);
assert_eq!(marker_color_with_opacity(color(1.0), 2.0).a, 0.9);
}
#[test]
fn provisional_polygon_bounds_include_extra_preview_padding() {
let stroke = Tool::Triangle.provisional_polygon_stroke(PolygonProvisionalSnapshot {
tool: Tool::Triangle,
start: (10, 10),
current: (60, 50),
color: color(1.0),
size: 4.0,
fill_enabled: false,
regular_sides: 5,
});
let ProvisionalToolStroke::Shape(shape @ Shape::Polygon { .. }) = &stroke else {
panic!("expected provisional polygon shape");
};
let base = shape
.bounding_box()
.expect("polygon preview should have bounds");
assert_eq!(
stroke.bounds(),
base.inflated(PROVISIONAL_POLYGON_DAMAGE_PADDING),
"polygon drag preview damage should clear antialias leftovers"
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
use super::types::{BoardPagesSnapshot, SessionSnapshot};
use crate::draw::BoardPages;
use crate::draw::{BoardPages, clamp_regular_sides};
use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
use crate::input::{InputState, PerToolDrawingSettings};
use crate::session::options::SessionOptions;
@@ -96,6 +96,7 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options
if let Some(label_enabled) = tool_state.arrow_label_enabled {
input.arrow_label_enabled = label_enabled;
}
input.polygon_sides = clamp_regular_sides(tool_state.polygon_sides);
input.board_previous_color = tool_state.board_previous_color;
input.show_status_bar = tool_state.show_status_bar;
} else {
+1
View File
@@ -74,6 +74,7 @@ fn sample_tool_state() -> ToolStateSnapshot {
arrow_angle: 30.0,
arrow_head_at_end: Some(false),
arrow_label_enabled: Some(false),
polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES,
board_previous_color: None,
show_status_bar: true,
tool_settings: None,
+8 -1
View File
@@ -1,4 +1,4 @@
use crate::draw::{Color, EraserKind, FontDescriptor, Frame};
use crate::draw::{Color, EraserKind, FontDescriptor, Frame, REGULAR_POLYGON_DEFAULT_SIDES};
use crate::input::{EraserMode, InputState, PerToolDrawingSettings, Tool};
use serde::{Deserialize, Serialize};
@@ -72,6 +72,8 @@ pub struct ToolStateSnapshot {
pub arrow_head_at_end: Option<bool>,
#[serde(default)]
pub arrow_label_enabled: Option<bool>,
#[serde(default = "default_polygon_sides_for_snapshot")]
pub polygon_sides: u8,
pub board_previous_color: Option<Color>,
pub show_status_bar: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -97,6 +99,7 @@ impl ToolStateSnapshot {
arrow_angle: input.arrow_angle,
arrow_head_at_end: Some(input.arrow_head_at_end),
arrow_label_enabled: Some(input.arrow_label_enabled),
polygon_sides: input.polygon_sides,
board_previous_color: input.board_previous_color,
show_status_bar: input.session_show_status_bar(),
tool_settings: Some(input.tool_settings.clone()),
@@ -116,6 +119,10 @@ fn default_eraser_mode_for_snapshot() -> EraserMode {
EraserMode::Brush
}
fn default_polygon_sides_for_snapshot() -> u8 {
REGULAR_POLYGON_DEFAULT_SIDES
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct SessionFile {
#[serde(default = "default_file_version")]

Some files were not shown because too many files have changed in this diff Show More