mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-06-03 03:54:42 +02:00
feat: add expanded geometry tools
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -94,6 +94,37 @@ 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 an arrow (line with arrowhead pointing towards the tip)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render_arrow(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ¤t 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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::util::Rect;
|
||||
|
||||
impl InputState {
|
||||
@@ -38,6 +40,18 @@ 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)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ..
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) enum ConsumedBy {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ActiveInteractionKind {
|
||||
Drawing,
|
||||
BuildingPolygon,
|
||||
TextInput,
|
||||
PendingTextClick,
|
||||
MovingSelection,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use crate::draw::render::render_freehand_pressure_borrowed;
|
||||
use crate::draw::{
|
||||
Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape,
|
||||
Color, PolygonKind, 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 +27,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 +178,33 @@ 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);
|
||||
}
|
||||
if preview_points.len() >= 3 {
|
||||
render_shape(
|
||||
ctx,
|
||||
&Shape::Polygon {
|
||||
kind: PolygonKind::Freeform,
|
||||
points: preview_points,
|
||||
fill: *fill,
|
||||
color: *color,
|
||||
thick: *thick,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
render_freehand_borrowed(ctx, &preview_points, *color, *thick);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,6 +65,158 @@ 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_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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
-1
@@ -1,4 +1,7 @@
|
||||
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,
|
||||
@@ -27,6 +30,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 +72,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 +155,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 +285,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 +328,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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, PolygonProvisionalSnapshot, PolygonStrokeSnapshot, ProvisionalToolSnapshot,
|
||||
ProvisionalToolStroke, ToolStrokeSnapshot,
|
||||
};
|
||||
pub use kind::Tool;
|
||||
pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource};
|
||||
|
||||
+35
-2
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -252,6 +252,7 @@ fn inspect_session_reports_counts_and_flags() {
|
||||
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user