From 9884d4584f378dbd48cd87d0ea085b7164ec3e74 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Wed, 18 Feb 2026 11:55:00 +0100
Subject: [PATCH] feat: make drag tool mappings configurable
Add configurable [drawing] drag/modifier tool bindings with legacy defaults.
Wire runtime + configurator support, docs/examples, and tests.
Fix highlight active-state for drag-mapped highlight tool.
---
README.md | 2 +
config.example.toml | 8 ++
configurator/src/app/update/fields.rs | 17 ++++-
configurator/src/app/update/mod.rs | 3 +
configurator/src/app/view/drawing/mod.rs | 56 +++++++++++++-
configurator/src/messages.rs | 12 +--
.../src/models/config/draft/from_config.rs | 9 ++-
configurator/src/models/config/draft/mod.rs | 7 +-
configurator/src/models/config/setters.rs | 14 +++-
configurator/src/models/config/tests.rs | 41 ++++++++++-
.../src/models/config/to_config/drawing.rs | 5 ++
configurator/src/models/fields/mod.rs | 2 +-
configurator/src/models/fields/tool.rs | 21 ++++++
configurator/src/models/mod.rs | 2 +-
docs/CONFIG.md | 8 ++
.../wayland/backend/state_init/input_state.rs | 31 +++++++-
src/config/tests/validate.rs | 14 ++++
src/config/types/arrow.rs | 2 +-
src/config/types/drawing.rs | 47 +++++++++++-
src/input/mod.rs | 2 +-
src/input/modifiers.rs | 73 ++++++++++++++++---
src/input/state/core/base/state/init.rs | 7 +-
src/input/state/core/base/state/structs.rs | 4 +-
src/input/state/core/highlight_controls.rs | 18 ++---
.../state/core/tool_controls/settings.rs | 16 +++-
src/input/state/tests/drawing.rs | 46 ++++++++++++
src/input/tool.rs | 2 +-
27 files changed, 425 insertions(+), 44 deletions(-)
diff --git a/README.md b/README.md
index bafa8104..970ff614 100644
--- a/README.md
+++ b/README.md
@@ -437,6 +437,8 @@ Press F1 for the complete in-app cheat sheet.
| Text mode | T, Click to position, type, Enter to finish |
| Sticky note | N, Click to place, type, Enter 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.
+
diff --git a/config.example.toml b/config.example.toml
index cdb7c668..ab42ade0 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -666,6 +666,14 @@ hit_test_linear_threshold = 400
# Undo history size (10 - 1000)
undo_stack_limit = 100
+# Drag gesture tool mapping (defaults match existing behavior)
+# Allowed values use kebab-case tool names, e.g. "pen", "arrow", "eraser"
+drag_tool = "pen"
+shift_drag_tool = "line"
+ctrl_drag_tool = "rect"
+ctrl_shift_drag_tool = "arrow"
+tab_drag_tool = "ellipse"
+
# ───────────────────────────────────────────────────────────────────────────────
# Font Configuration (Pango-based text rendering)
# ───────────────────────────────────────────────────────────────────────────────
diff --git a/configurator/src/app/update/fields.rs b/configurator/src/app/update/fields.rs
index fc9d8be9..1b21e305 100644
--- a/configurator/src/app/update/fields.rs
+++ b/configurator/src/app/update/fields.rs
@@ -2,10 +2,10 @@ use iced::Command;
use crate::messages::Message;
use crate::models::{
- ColorMode, ColorPickerId, EraserModeOption, FontStyleOption, FontWeightOption, KeybindingField,
- NamedColorOption, OverrideOption, PresenterToolBehaviorOption, QuadField,
+ ColorMode, ColorPickerId, DragToolField, EraserModeOption, FontStyleOption, FontWeightOption,
+ KeybindingField, NamedColorOption, OverrideOption, PresenterToolBehaviorOption, QuadField,
SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField,
- ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField,
+ ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField,
};
#[cfg(feature = "tablet-input")]
use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
@@ -105,6 +105,17 @@ impl ConfiguratorApp {
Command::none()
}
+ pub(super) fn handle_drawing_drag_tool_changed(
+ &mut self,
+ field: DragToolField,
+ option: ToolOption,
+ ) -> Command {
+ self.status = StatusMessage::idle();
+ self.draft.set_drag_tool(field, option);
+ self.refresh_dirty_flag();
+ Command::none()
+ }
+
pub(super) fn handle_status_position_changed(
&mut self,
option: StatusPositionOption,
diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs
index 9d2dd12d..7dee1074 100644
--- a/configurator/src/app/update/mod.rs
+++ b/configurator/src/app/update/mod.rs
@@ -41,6 +41,9 @@ impl ConfiguratorApp {
Message::ColorModeChanged(mode) => self.handle_color_mode_changed(mode),
Message::NamedColorSelected(option) => self.handle_named_color_selected(option),
Message::EraserModeChanged(option) => self.handle_eraser_mode_changed(option),
+ Message::DrawingDragToolChanged(field, option) => {
+ self.handle_drawing_drag_tool_changed(field, option)
+ }
Message::StatusPositionChanged(option) => self.handle_status_position_changed(option),
Message::ToolbarLayoutModeChanged(option) => {
self.handle_toolbar_layout_mode_changed(option)
diff --git a/configurator/src/app/view/drawing/mod.rs b/configurator/src/app/view/drawing/mod.rs
index 432abd3e..efc6ada1 100644
--- a/configurator/src/app/view/drawing/mod.rs
+++ b/configurator/src/app/view/drawing/mod.rs
@@ -5,7 +5,7 @@ use iced::widget::{column, pick_list, row, scrollable, text};
use iced::{Element, Length};
use crate::messages::Message;
-use crate::models::{EraserModeOption, TextField, ToggleField};
+use crate::models::{DragToolField, EraserModeOption, TextField, ToggleField, ToolOption};
use self::color::drawing_color_block;
use self::font::font_controls;
@@ -22,6 +22,12 @@ impl ConfiguratorApp {
Some(self.draft.drawing_default_eraser_mode),
Message::EraserModeChanged,
);
+ let drag_tool_pick = |field: DragToolField, selected: ToolOption| {
+ pick_list(ToolOption::list(), Some(selected), move |option| {
+ Message::DrawingDragToolChanged(field, option)
+ })
+ .width(Length::Fill)
+ };
let column = column![
text("Drawing Defaults").size(20),
@@ -66,6 +72,54 @@ impl ConfiguratorApp {
)
]
.spacing(12),
+ text("Drag Tool Mapping").size(16),
+ row![
+ labeled_control(
+ DragToolField::Drag.label(),
+ drag_tool_pick(DragToolField::Drag, self.draft.drawing_drag_tool).into(),
+ self.defaults.drawing_drag_tool.label().to_string(),
+ self.draft.drawing_drag_tool != self.defaults.drawing_drag_tool,
+ ),
+ labeled_control(
+ DragToolField::ShiftDrag.label(),
+ drag_tool_pick(DragToolField::ShiftDrag, self.draft.drawing_shift_drag_tool)
+ .into(),
+ self.defaults.drawing_shift_drag_tool.label().to_string(),
+ self.draft.drawing_shift_drag_tool != self.defaults.drawing_shift_drag_tool,
+ )
+ ]
+ .spacing(12),
+ row![
+ labeled_control(
+ DragToolField::CtrlDrag.label(),
+ drag_tool_pick(DragToolField::CtrlDrag, self.draft.drawing_ctrl_drag_tool)
+ .into(),
+ self.defaults.drawing_ctrl_drag_tool.label().to_string(),
+ self.draft.drawing_ctrl_drag_tool != self.defaults.drawing_ctrl_drag_tool,
+ ),
+ labeled_control(
+ DragToolField::CtrlShiftDrag.label(),
+ drag_tool_pick(
+ DragToolField::CtrlShiftDrag,
+ self.draft.drawing_ctrl_shift_drag_tool,
+ )
+ .into(),
+ self.defaults
+ .drawing_ctrl_shift_drag_tool
+ .label()
+ .to_string(),
+ self.draft.drawing_ctrl_shift_drag_tool
+ != self.defaults.drawing_ctrl_shift_drag_tool,
+ )
+ ]
+ .spacing(12),
+ row![labeled_control(
+ DragToolField::TabDrag.label(),
+ drag_tool_pick(DragToolField::TabDrag, self.draft.drawing_tab_drag_tool).into(),
+ self.defaults.drawing_tab_drag_tool.label().to_string(),
+ self.draft.drawing_tab_drag_tool != self.defaults.drawing_tab_drag_tool,
+ )]
+ .spacing(12),
row![
labeled_input_with_feedback(
"Marker opacity (0.05-0.9)",
diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs
index 2668d5c6..a7c4ff8d 100644
--- a/configurator/src/messages.rs
+++ b/configurator/src/messages.rs
@@ -5,11 +5,12 @@ use wayscriber::config::Config;
use crate::models::{
BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId,
- ColorPickerValue, EraserModeOption, FontStyleOption, FontWeightOption, KeybindingField,
- KeybindingsTabId, NamedColorOption, OverrideOption, PresenterToolBehaviorOption,
- PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField,
- SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField,
- ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId,
+ ColorPickerValue, DragToolField, EraserModeOption, FontStyleOption, FontWeightOption,
+ KeybindingField, KeybindingsTabId, NamedColorOption, OverrideOption,
+ PresenterToolBehaviorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField,
+ PresetToggleField, QuadField, SessionCompressionOption, SessionStorageModeOption,
+ StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption,
+ ToolbarOverrideField, TripletField, UiTabId,
};
#[cfg(feature = "tablet-input")]
use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
@@ -35,6 +36,7 @@ pub enum Message {
ColorModeChanged(ColorMode),
NamedColorSelected(NamedColorOption),
EraserModeChanged(EraserModeOption),
+ DrawingDragToolChanged(DragToolField, ToolOption),
StatusPositionChanged(StatusPositionOption),
ToolbarLayoutModeChanged(ToolbarLayoutModeOption),
ToolbarOverrideModeChanged(ToolbarLayoutModeOption),
diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index 6c57b290..9b7cc651 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -1,7 +1,7 @@
use super::super::super::color::{ColorInput, ColorQuadInput};
use super::super::super::fields::{
EraserModeOption, FontStyleOption, FontWeightOption, PresenterToolBehaviorOption,
- SessionCompressionOption, SessionStorageModeOption, StatusPositionOption,
+ SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
ToolbarLayoutModeOption,
};
#[cfg(feature = "tablet-input")]
@@ -38,6 +38,13 @@ 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(
+ config.drawing.ctrl_shift_drag_tool,
+ ),
+ drawing_tab_drag_tool: ToolOption::from_tool(config.drawing.tab_drag_tool),
drawing_font_style_option: style_option,
drawing_font_weight_option: weight_option,
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 80c69727..64fc0e48 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -3,7 +3,7 @@ mod from_config;
use super::super::color::{ColorInput, ColorQuadInput};
use super::super::fields::{
EraserModeOption, FontStyleOption, FontWeightOption, PresenterToolBehaviorOption,
- SessionCompressionOption, SessionStorageModeOption, StatusPositionOption,
+ SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
ToolbarLayoutModeOption,
};
#[cfg(feature = "tablet-input")]
@@ -29,6 +29,11 @@ pub struct ConfigDraft {
pub drawing_font_style: String,
pub drawing_text_background_enabled: bool,
pub drawing_default_fill_enabled: bool,
+ pub drawing_drag_tool: ToolOption,
+ pub drawing_shift_drag_tool: ToolOption,
+ pub drawing_ctrl_drag_tool: ToolOption,
+ pub drawing_ctrl_shift_drag_tool: ToolOption,
+ pub drawing_tab_drag_tool: ToolOption,
pub drawing_font_style_option: FontStyleOption,
pub drawing_font_weight_option: FontWeightOption,
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 8037438a..dabaff87 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -1,6 +1,6 @@
use super::super::fields::{
- FontStyleOption, FontWeightOption, OverrideOption, QuadField, TextField, ToggleField,
- ToolbarLayoutModeOption, ToolbarOverrideField, TripletField,
+ DragToolField, FontStyleOption, FontWeightOption, OverrideOption, QuadField, TextField,
+ ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField,
};
use super::draft::ConfigDraft;
@@ -30,6 +30,16 @@ impl ConfigDraft {
.set(field, value);
}
+ pub fn set_drag_tool(&mut self, field: DragToolField, value: ToolOption) {
+ match field {
+ DragToolField::Drag => self.drawing_drag_tool = value,
+ DragToolField::ShiftDrag => self.drawing_shift_drag_tool = value,
+ DragToolField::CtrlDrag => self.drawing_ctrl_drag_tool = value,
+ DragToolField::CtrlShiftDrag => self.drawing_ctrl_shift_drag_tool = value,
+ DragToolField::TabDrag => self.drawing_tab_drag_tool = value,
+ }
+ }
+
pub fn set_toggle(&mut self, field: ToggleField, value: bool) {
match field {
ToggleField::DrawingTextBackground => {
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index 38b437e7..42666cf2 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -1,6 +1,7 @@
use super::super::color::ColorInput;
use super::super::fields::{
- FontWeightOption, QuadField, SessionStorageModeOption, TextField, ToggleField, TripletField,
+ FontWeightOption, QuadField, SessionStorageModeOption, TextField, ToggleField, ToolOption,
+ TripletField,
};
use super::super::{ColorMode, NamedColorOption};
use super::ConfigDraft;
@@ -131,3 +132,41 @@ fn config_draft_round_trips_presets_and_history() {
assert_eq!(round_trip.presets.slot_count, config.presets.slot_count);
assert_eq!(round_trip.presets.get_slot(1), config.presets.get_slot(1));
}
+
+#[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;
+
+ let draft = ConfigDraft::from_config(&config);
+ assert_eq!(draft.drawing_drag_tool, ToolOption::Arrow);
+ assert_eq!(draft.drawing_shift_drag_tool, ToolOption::Eraser);
+ assert_eq!(draft.drawing_ctrl_drag_tool, ToolOption::Pen);
+ assert_eq!(draft.drawing_ctrl_shift_drag_tool, ToolOption::Rect);
+ assert_eq!(draft.drawing_tab_drag_tool, ToolOption::Ellipse);
+
+ let round_trip = draft
+ .to_config(&config)
+ .expect("expected config to round trip");
+ assert_eq!(round_trip.drawing.drag_tool, config.drawing.drag_tool);
+ assert_eq!(
+ round_trip.drawing.shift_drag_tool,
+ config.drawing.shift_drag_tool
+ );
+ assert_eq!(
+ round_trip.drawing.ctrl_drag_tool,
+ config.drawing.ctrl_drag_tool
+ );
+ assert_eq!(
+ round_trip.drawing.ctrl_shift_drag_tool,
+ config.drawing.ctrl_shift_drag_tool
+ );
+ assert_eq!(
+ round_trip.drawing.tab_drag_tool,
+ config.drawing.tab_drag_tool
+ );
+}
diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs
index 05f501f8..c932346c 100644
--- a/configurator/src/models/config/to_config/drawing.rs
+++ b/configurator/src/models/config/to_config/drawing.rs
@@ -39,6 +39,11 @@ 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 = self.drawing_drag_tool.to_tool();
+ config.drawing.shift_drag_tool = self.drawing_shift_drag_tool.to_tool();
+ config.drawing.ctrl_drag_tool = self.drawing_ctrl_drag_tool.to_tool();
+ config.drawing.ctrl_shift_drag_tool = self.drawing_ctrl_shift_drag_tool.to_tool();
+ config.drawing.tab_drag_tool = self.drawing_tab_drag_tool.to_tool();
parse_field(
&self.drawing_hit_test_tolerance,
"drawing.hit_test_tolerance",
diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs
index 4f4f81a0..1f751ee7 100644
--- a/configurator/src/models/fields/mod.rs
+++ b/configurator/src/models/fields/mod.rs
@@ -19,7 +19,7 @@ pub use status::StatusPositionOption;
pub use toggles::{
PresetTextField, PresetToggleField, QuadField, TextField, ToggleField, TripletField,
};
-pub use tool::ToolOption;
+pub use tool::{DragToolField, ToolOption};
pub use toolbar::{OverrideOption, ToolbarLayoutModeOption, ToolbarOverrideField};
#[cfg(test)]
diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs
index 415ecdbc..fcab813d 100644
--- a/configurator/src/models/fields/tool.rs
+++ b/configurator/src/models/fields/tool.rs
@@ -1,5 +1,26 @@
use wayscriber::input::Tool;
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DragToolField {
+ Drag,
+ ShiftDrag,
+ CtrlDrag,
+ CtrlShiftDrag,
+ TabDrag,
+}
+
+impl DragToolField {
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Drag => "Drag",
+ Self::ShiftDrag => "Shift+Drag",
+ Self::CtrlDrag => "Ctrl+Drag",
+ Self::CtrlShiftDrag => "Ctrl+Shift+Drag",
+ Self::TabDrag => "Tab+Drag",
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOption {
Select,
diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs
index d8567712..acc5caf3 100644
--- a/configurator/src/models/mod.rs
+++ b/configurator/src/models/mod.rs
@@ -11,7 +11,7 @@ pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption};
pub use color_picker::{ColorPickerId, ColorPickerValue};
pub use config::{BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft};
pub use fields::{
- EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption,
+ DragToolField, EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption,
PresenterToolBehaviorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField,
PresetToggleField, QuadField, SessionCompressionOption, SessionStorageModeOption,
StatusPositionOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 565da1b7..0310dd32 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -61,6 +61,13 @@ text_background_enabled = false
hit_test_tolerance = 6.0
hit_test_linear_threshold = 400
undo_stack_limit = 100
+
+# Drag gesture tool mapping
+drag_tool = "pen"
+shift_drag_tool = "line"
+ctrl_drag_tool = "rect"
+ctrl_shift_drag_tool = "arrow"
+tab_drag_tool = "ellipse"
```
**Color Options:**
@@ -86,6 +93,7 @@ undo_stack_limit = 100
- Text background: false
- Hit-test tolerance: 6.0px (linear threshold: 400)
- Undo stack limit: 100
+- Drag mapping: Drag=Pen, Shift+Drag=Line, Ctrl+Drag=Rect, Ctrl+Shift+Drag=Arrow, Tab+Drag=Ellipse
### `[arrow]` - Arrow Geometry
diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs
index b83324f8..9a03dc4f 100644
--- a/src/backend/wayland/backend/state_init/input_state.rs
+++ b/src/backend/wayland/backend/state_init/input_state.rs
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use crate::config::{Action, Config, KeyBinding, KeybindingsConfig};
use crate::draw::FontDescriptor;
-use crate::input::{ClickHighlightSettings, InputState};
+use crate::input::{ClickHighlightSettings, InputState, modifiers::DragToolBindings};
pub(super) fn build_input_state(config: &Config) -> InputState {
let font_descriptor = FontDescriptor::new(
@@ -43,6 +43,13 @@ pub(super) fn build_input_state(config: &Config) -> InputState {
config.presenter_mode.clone(),
);
input_state.set_action_bindings(action_bindings);
+ input_state.set_drag_tool_bindings(DragToolBindings {
+ drag: config.drawing.drag_tool,
+ shift_drag: config.drawing.shift_drag_tool,
+ ctrl_drag: config.drawing.ctrl_drag_tool,
+ ctrl_shift_drag: config.drawing.ctrl_shift_drag_tool,
+ tab_drag: config.drawing.tab_drag_tool,
+ });
input_state.set_hit_test_tolerance(config.drawing.hit_test_tolerance);
input_state.set_hit_test_threshold(config.drawing.hit_test_linear_threshold);
@@ -183,4 +190,26 @@ mod tests {
assert!(input.show_active_output_badge);
assert_eq!(input.command_palette_toast_duration_ms, 1234);
}
+
+ #[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;
+
+ let input = build_input_state(&config);
+ assert_eq!(
+ input.drag_tool_bindings,
+ crate::input::DragToolBindings {
+ drag: crate::input::Tool::Arrow,
+ shift_drag: crate::input::Tool::Eraser,
+ ctrl_drag: crate::input::Tool::Pen,
+ ctrl_shift_drag: crate::input::Tool::Rect,
+ tab_drag: crate::input::Tool::Ellipse,
+ }
+ );
+ }
}
diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs
index 1eb2b2f7..429f35d1 100644
--- a/src/config/tests/validate.rs
+++ b/src/config/tests/validate.rs
@@ -228,3 +228,17 @@ fn validate_does_not_clamp_autosave_interval_to_idle() {
assert_eq!(config.session.autosave_idle_ms, 60_000);
assert_eq!(config.session.autosave_interval_ms, 5_000);
}
+
+#[test]
+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.ctrl_shift_drag_tool,
+ crate::input::Tool::Arrow
+ );
+ assert_eq!(config.drawing.tab_drag_tool, crate::input::Tool::Ellipse);
+}
diff --git a/src/config/types/arrow.rs b/src/config/types/arrow.rs
index 1e0a7cfb..6f9e39d9 100644
--- a/src/config/types/arrow.rs
+++ b/src/config/types/arrow.rs
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
/// Arrow drawing settings.
///
-/// Controls the appearance of arrowheads when using the arrow tool (Ctrl+Shift+Drag).
+/// Controls the appearance of arrowheads when using the arrow tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ArrowConfig {
/// Arrowhead length in pixels (valid range: 5.0 - 50.0)
diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs
index aa783079..52b245b2 100644
--- a/src/config/types/drawing.rs
+++ b/src/config/types/drawing.rs
@@ -1,5 +1,5 @@
use crate::config::enums::ColorSpec;
-use crate::input::EraserMode;
+use crate::input::{EraserMode, Tool};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -50,6 +50,26 @@ pub struct DrawingConfig {
#[serde(default = "default_undo_stack_limit")]
pub undo_stack_limit: usize,
+ /// Tool used for drag with no modifier.
+ #[serde(default = "default_drag_tool")]
+ pub drag_tool: Tool,
+
+ /// Tool used for Shift+drag.
+ #[serde(default = "default_shift_drag_tool")]
+ pub shift_drag_tool: Tool,
+
+ /// Tool used for Ctrl+drag.
+ #[serde(default = "default_ctrl_drag_tool")]
+ pub ctrl_drag_tool: Tool,
+
+ /// Tool used for Ctrl+Shift+drag.
+ #[serde(default = "default_ctrl_shift_drag_tool")]
+ pub ctrl_shift_drag_tool: Tool,
+
+ /// Tool used for Tab+drag.
+ #[serde(default = "default_tab_drag_tool")]
+ pub tab_drag_tool: Tool,
+
/// Font family name for text rendering (e.g., "Sans", "Monospace", "JetBrains Mono")
/// Falls back to "Sans" if the specified font is not available
/// Note: Install fonts system-wide and reference by family name
@@ -83,6 +103,11 @@ impl Default for DrawingConfig {
hit_test_tolerance: default_hit_test_tolerance(),
hit_test_linear_threshold: default_hit_test_threshold(),
undo_stack_limit: default_undo_stack_limit(),
+ drag_tool: default_drag_tool(),
+ shift_drag_tool: default_shift_drag_tool(),
+ ctrl_drag_tool: default_ctrl_drag_tool(),
+ ctrl_shift_drag_tool: default_ctrl_shift_drag_tool(),
+ tab_drag_tool: default_tab_drag_tool(),
font_family: default_font_family(),
font_weight: default_font_weight(),
font_style: default_font_style(),
@@ -146,3 +171,23 @@ fn default_hit_test_threshold() -> usize {
fn default_undo_stack_limit() -> usize {
100
}
+
+fn default_drag_tool() -> Tool {
+ Tool::Pen
+}
+
+fn default_shift_drag_tool() -> Tool {
+ Tool::Line
+}
+
+fn default_ctrl_drag_tool() -> Tool {
+ Tool::Rect
+}
+
+fn default_ctrl_shift_drag_tool() -> Tool {
+ Tool::Arrow
+}
+
+fn default_tab_drag_tool() -> Tool {
+ Tool::Ellipse
+}
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 7f3a49a3..83008a36 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -32,4 +32,4 @@ pub use tool::{EraserMode, Tool};
// Re-export for public API (unused internally but part of public interface)
#[allow(unused_imports)]
-pub use modifiers::Modifiers;
+pub use modifiers::{DragModifier, DragToolBindings, Modifiers};
diff --git a/src/input/modifiers.rs b/src/input/modifiers.rs
index d840cb69..eecbdf27 100644
--- a/src/input/modifiers.rs
+++ b/src/input/modifiers.rs
@@ -2,6 +2,56 @@
use super::tool::Tool;
+/// Active drag modifier combination used for tool mapping.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DragModifier {
+ None,
+ Shift,
+ Ctrl,
+ CtrlShift,
+ Tab,
+}
+
+impl DragModifier {
+ pub fn is_active(self) -> bool {
+ !matches!(self, Self::None)
+ }
+}
+
+/// Tool mapping for drag gestures with optional modifiers.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DragToolBindings {
+ pub drag: Tool,
+ pub shift_drag: Tool,
+ pub ctrl_drag: Tool,
+ pub ctrl_shift_drag: Tool,
+ pub tab_drag: Tool,
+}
+
+impl Default for DragToolBindings {
+ fn default() -> Self {
+ Self {
+ drag: Tool::Pen,
+ shift_drag: Tool::Line,
+ ctrl_drag: Tool::Rect,
+ ctrl_shift_drag: Tool::Arrow,
+ tab_drag: Tool::Ellipse,
+ }
+ }
+}
+
+impl DragToolBindings {
+ pub fn tool_for_modifier(self, modifier: DragModifier) -> Tool {
+ match modifier {
+ DragModifier::None => self.drag,
+ DragModifier::Shift => self.shift_drag,
+ DragModifier::Ctrl => self.ctrl_drag,
+ DragModifier::CtrlShift => self.ctrl_shift_drag,
+ DragModifier::Tab => self.tab_drag,
+ }
+ }
+}
+
/// Keyboard modifier state.
///
/// Tracks which modifier keys (Shift, Ctrl, Alt, Tab) are currently pressed.
@@ -35,25 +85,30 @@ impl Modifiers {
}
}
- /// Determines which drawing tool is active based on current modifier state.
+ /// Returns the active drag modifier combination using the default priority.
///
- /// # Tool Selection Priority
+ /// # Priority
/// 1. Ctrl+Shift → Arrow
/// 2. Ctrl → Rectangle
/// 3. Shift → Line
/// 4. Tab → Ellipse
- /// 5. None → Pen (default)
- pub fn current_tool(&self) -> Tool {
+ /// 5. None
+ pub fn active_drag_modifier(&self) -> DragModifier {
if self.ctrl && self.shift {
- Tool::Arrow
+ DragModifier::CtrlShift
} else if self.ctrl {
- Tool::Rect
+ DragModifier::Ctrl
} else if self.shift {
- Tool::Line
+ DragModifier::Shift
} else if self.tab {
- Tool::Ellipse
+ DragModifier::Tab
} else {
- Tool::Pen
+ DragModifier::None
}
}
+
+ /// Determines which drawing tool is active based on current modifier state and drag mapping.
+ pub fn current_tool_with_bindings(&self, bindings: DragToolBindings) -> Tool {
+ bindings.tool_for_modifier(self.active_drag_modifier())
+ }
}
diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index e4e6903a..9465047f 100644
--- a/src/input/state/core/base/state/init.rs
+++ b/src/input/state/core/base/state/init.rs
@@ -11,7 +11,11 @@ use super::structs::InputState;
use crate::config::{Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX, RadialMenuMouseBinding};
use crate::draw::{DirtyTracker, EraserKind, FontDescriptor};
use crate::input::state::highlight::{ClickHighlightSettings, ClickHighlightState};
-use crate::input::{BoardManager, modifiers::Modifiers, tool::EraserMode};
+use crate::input::{
+ BoardManager,
+ modifiers::{DragToolBindings, Modifiers},
+ tool::EraserMode,
+};
use std::collections::HashMap;
impl InputState {
@@ -88,6 +92,7 @@ impl InputState {
arrow_label_counter: 1,
step_marker_counter: 1,
modifiers: Modifiers::new(),
+ drag_tool_bindings: DragToolBindings::default(),
state: DrawingState::Idle,
should_exit: false,
needs_redraw: true,
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index a4f27197..b9ec7083 100644
--- a/src/input/state/core/base/state/structs.rs
+++ b/src/input/state/core/base/state/structs.rs
@@ -27,7 +27,7 @@ use crate::input::BoardManager;
use crate::input::boards::BoardState;
use crate::input::state::highlight::ClickHighlightState;
use crate::input::{
- modifiers::Modifiers,
+ modifiers::{DragToolBindings, Modifiers},
tool::{EraserMode, Tool},
};
use crate::util::Rect;
@@ -93,6 +93,8 @@ pub struct InputState {
pub step_marker_counter: u32,
/// Current modifier key state
pub modifiers: Modifiers,
+ /// Tool mapping for drag gestures with modifier keys
+ pub drag_tool_bindings: DragToolBindings,
/// Current drawing mode state machine
pub state: DrawingState,
/// Whether user requested to exit the overlay
diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs
index 3a266974..3db55e56 100644
--- a/src/input/state/core/highlight_controls.rs
+++ b/src/input/state/core/highlight_controls.rs
@@ -84,15 +84,18 @@ impl InputState {
return *tool;
}
- let modifier_tool = self.modifiers.current_tool();
+ let drag_modifier = self.modifiers.active_drag_modifier();
+ let modifier_tool = self
+ .modifiers
+ .current_tool_with_bindings(self.drag_tool_bindings);
if let Some(override_tool) = self.tool_override {
if matches!(override_tool, Tool::Highlight | Tool::Eraser) {
return override_tool;
}
- // Allow temporary modifier-based tools when the override is a drawing tool
- if modifier_tool != Tool::Pen && modifier_tool != override_tool {
+ // Allow temporary modifier-based tools when an override is active.
+ if drag_modifier.is_active() && modifier_tool != override_tool {
return modifier_tool;
}
@@ -104,14 +107,7 @@ impl InputState {
/// Returns whether the highlight tool is currently selected.
pub fn highlight_tool_active(&self) -> bool {
- matches!(self.tool_override, Some(Tool::Highlight))
- || matches!(
- self.state,
- DrawingState::Drawing {
- tool: Tool::Highlight,
- ..
- }
- )
+ self.active_tool() == Tool::Highlight
}
/// Sets highlight-only tool mode on/off and keeps click highlight in sync.
diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs
index bbad9f5f..f071bbd8 100644
--- a/src/input/state/core/tool_controls/settings.rs
+++ b/src/input/state/core/tool_controls/settings.rs
@@ -1,6 +1,9 @@
use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
use crate::draw::{Color, FontDescriptor};
-use crate::input::tool::{EraserMode, Tool};
+use crate::input::{
+ modifiers::DragToolBindings,
+ tool::{EraserMode, Tool},
+};
impl InputState {
/// Sets or clears an explicit tool override. Returns true if the tool changed.
@@ -53,6 +56,17 @@ impl InputState {
self.tool_override
}
+ /// Sets drag modifier -> tool mappings. Returns true if changed.
+ pub fn set_drag_tool_bindings(&mut self, bindings: DragToolBindings) -> bool {
+ if self.drag_tool_bindings == bindings {
+ return false;
+ }
+ self.drag_tool_bindings = bindings;
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ true
+ }
+
/// Sets thickness or eraser size depending on the active tool.
pub fn set_thickness_for_active_tool(&mut self, value: f64) -> bool {
match self.active_tool() {
diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs
index 6c3f64c8..662ab092 100644
--- a/src/input/state/tests/drawing.rs
+++ b/src/input/state/tests/drawing.rs
@@ -1,4 +1,5 @@
use super::*;
+use crate::input::DragToolBindings;
#[test]
fn mouse_drag_creates_shapes_for_each_tool() {
@@ -43,6 +44,51 @@ fn mouse_drag_creates_shapes_for_each_tool() {
assert_eq!(state.boards.active_frame().shapes.len(), 5);
}
+#[test]
+fn custom_drag_bindings_remap_default_and_modifier_tools() {
+ let mut state = create_test_input_state();
+ assert!(state.set_drag_tool_bindings(DragToolBindings {
+ drag: Tool::Arrow,
+ shift_drag: Tool::Eraser,
+ ctrl_drag: Tool::Pen,
+ ctrl_shift_drag: Tool::Rect,
+ tab_drag: Tool::Ellipse,
+ }));
+
+ assert_eq!(state.active_tool(), Tool::Arrow);
+ assert!(state.set_tool_override(Some(Tool::Arrow)));
+ assert_eq!(state.active_tool(), Tool::Arrow);
+
+ state.modifiers.ctrl = true;
+ assert_eq!(state.active_tool(), Tool::Pen);
+
+ state.modifiers.ctrl = false;
+ state.modifiers.shift = true;
+ assert_eq!(state.active_tool(), Tool::Eraser);
+
+ state.modifiers.ctrl = true;
+ assert_eq!(state.active_tool(), Tool::Rect);
+}
+
+#[test]
+fn drag_mapped_highlight_reports_highlight_active() {
+ let mut state = create_test_input_state();
+ assert!(state.set_drag_tool_bindings(DragToolBindings {
+ drag: Tool::Highlight,
+ shift_drag: Tool::Line,
+ ctrl_drag: Tool::Rect,
+ ctrl_shift_drag: Tool::Arrow,
+ tab_drag: Tool::Ellipse,
+ }));
+
+ assert_eq!(state.active_tool(), Tool::Highlight);
+ assert!(state.highlight_tool_active());
+
+ state.modifiers.shift = true;
+ assert_eq!(state.active_tool(), Tool::Line);
+ assert!(!state.highlight_tool_active());
+}
+
#[test]
fn toggle_click_highlight_action_changes_state() {
let mut state = create_test_input_state();
diff --git a/src/input/tool.rs b/src/input/tool.rs
index 5202e7ac..fb7ac859 100644
--- a/src/input/tool.rs
+++ b/src/input/tool.rs
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
/// Drawing tool selection.
///
/// The active tool determines what shape is created when the user drags the mouse.
-/// Tools are selected by holding modifier keys (Shift, Ctrl, Tab) while dragging.
+/// Drag modifier mappings are configurable via `[drawing]` drag-tool fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Tool {