feat(input): Wayland-Tablet-Protokoll für aktive Stifte (Lazarite) und Touchscreen-Pinch-to-Zoom
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m0s

This commit is contained in:
2026-10-06 14:58:59 +02:00
parent 5cb77b9af7
commit df72981a9a
5 changed files with 796 additions and 238 deletions
Generated
+125 -6
View File
@@ -284,6 +284,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.11.1" version = "0.11.1"
@@ -573,6 +579,18 @@ dependencies = [
"simdutf8", "simdutf8",
] ]
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "epaint" name = "epaint"
version = "0.27.2" version = "0.27.2"
@@ -812,7 +830,7 @@ dependencies = [
"presser", "presser",
"thiserror", "thiserror",
"winapi", "winapi",
"windows", "windows 0.52.0",
] ]
[[package]] [[package]]
@@ -866,6 +884,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.3" version = "0.5.3"
@@ -1021,6 +1045,7 @@ dependencies = [
"egui-wgpu", "egui-wgpu",
"egui-winit", "egui-winit",
"glam", "glam",
"octotablet",
"pollster", "pollster",
"printpdf", "printpdf",
"semver", "semver",
@@ -1418,6 +1443,25 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "octotablet"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c4b9470450ffa669cb3be55ef863dfd9592a06284cb291546363c514bb4aa3"
dependencies = [
"bitflags 2.13.2",
"cfg_aliases 0.2.2",
"enum_dispatch",
"raw-window-handle 0.6.2",
"smallvec",
"strum",
"thiserror",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"windows 0.54.0",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1965,6 +2009,28 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.119",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2519,7 +2585,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases 0.1.1",
"js-sys", "js-sys",
"log", "log",
"naga", "naga",
@@ -2545,7 +2611,7 @@ dependencies = [
"arrayvec", "arrayvec",
"bit-vec", "bit-vec",
"bitflags 2.13.2", "bitflags 2.13.2",
"cfg_aliases", "cfg_aliases 0.1.1",
"codespan-reporting", "codespan-reporting",
"indexmap", "indexmap",
"log", "log",
@@ -2574,7 +2640,7 @@ dependencies = [
"bit-set", "bit-set",
"bitflags 2.13.2", "bitflags 2.13.2",
"block", "block",
"cfg_aliases", "cfg_aliases 0.1.1",
"core-graphics-types", "core-graphics-types",
"d3d12", "d3d12",
"glow", "glow",
@@ -2661,7 +2727,19 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-implement",
"windows-interface",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@@ -2674,12 +2752,53 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"
@@ -2915,7 +3034,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"bytemuck", "bytemuck",
"calloop", "calloop",
"cfg_aliases", "cfg_aliases 0.1.1",
"core-foundation", "core-foundation",
"core-graphics", "core-graphics",
"cursor-icon", "cursor-icon",
+1
View File
@@ -38,6 +38,7 @@ semver = "1.0"
sha2 = "0.10" sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5" url = "2.5"
octotablet = "0.1.0"
[profile.dev] [profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
+609 -231
View File
@@ -52,6 +52,49 @@ fn update_window_title(
window.set_title(&title); window.set_title(&title);
} }
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook(
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
ui_state: &UiState,
window: &Window,
snapped_shape: Option<RecognizedShape>,
) {
let new_id = notebook.next_stroke_id();
let stroke_opt = if let Some(shape) = snapped_shape {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
let color = canvas.current_ink_color;
canvas.cancel_stroke();
Some(geometry::shape_to_stroke(
shape,
new_id,
color,
stroke_width,
))
} else {
canvas.finish_stroke(new_id)
};
if let Some(stroke) = stroke_opt {
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), manifest);
update_window_title(
window,
notebook,
manifest,
ui_state.active_tool,
autosave.status(),
);
window.request_redraw();
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?; let event_loop = EventLoop::new()?;
let window = Arc::new( let window = Arc::new(
@@ -63,6 +106,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?; let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?;
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
Ok(mgr) => {
println!("[Input] Octotablet Stylus-/Tablet-Manager erfolgreich initialisiert (Wayland/X11 Tablet-Protokoll aktiv).");
Some(mgr)
}
Err(err) => {
println!("[Input] Hinweis: Octotablet Tablet-Manager nicht aktiv ({err}). Nutze Standard-Event-Pipeline.");
None
}
};
// 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung)
let storage_base_dir = storage::default_storage_dir(); let storage_base_dir = storage::default_storage_dir();
let available_notebooks = storage::list_notebooks(&storage_base_dir); let available_notebooks = storage::list_notebooks(&storage_base_dir);
@@ -135,7 +189,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut modifiers = ModifiersState::empty(); let mut modifiers = ModifiersState::empty();
let mut is_mouse_drawing = false; let mut is_mouse_drawing = false;
let mut active_touch_id: Option<u64> = None;
// Multi-Touch & Pinch-to-Zoom Zustand
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
std::collections::HashMap::new();
let mut pinch_prev_distance: Option<f32> = None;
let mut pinch_prev_center: Option<(f32, f32)> = None;
let mut is_pinch_gesturing = false;
let mut is_touch_drawing = false;
let mut is_touch_panning = false;
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
let mut is_stylus_drawing = false;
let mut is_stylus_in_proximity = false;
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Viewport-Pan & Zoom-Zustand // Viewport-Pan & Zoom-Zustand
let mut is_panning = false; let mut is_panning = false;
@@ -201,6 +271,265 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
// Stylus-/Tablet-Events via Octotablet abfragen (Wayland/X11 Tablet-Protokoll für aktive Stifte wie Lazarite)
if let Some(ref mut mgr) = tablet_manager {
if let Ok(tablet_events) = mgr.pump() {
let scale = window.scale_factor() as f32;
let now = start_time.elapsed().as_secs_f64();
for t_event in tablet_events {
if let octotablet::events::Event::Tool { tool, event } = t_event {
let tool_is_hardware_eraser =
tool.tool_type == Some(octotablet::tool::Type::Eraser);
match event {
octotablet::events::ToolEvent::In { .. } => {
is_stylus_in_proximity = true;
stylus_tool_id = Some(tool.id());
}
octotablet::events::ToolEvent::Out
| octotablet::events::ToolEvent::Removed => {
is_stylus_in_proximity = false;
if stylus_tool_id == Some(tool.id()) {
stylus_tool_id = None;
if is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
commit_stroke_to_notebook(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&window,
stroke_snapped_shape.take(),
);
}
if is_erasing {
is_erasing = false;
}
}
}
octotablet::events::ToolEvent::Button { pressed, .. } => {
stylus_barrel_pressed = pressed;
if pressed && is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
canvas.cancel_stroke();
is_erasing = true;
window.request_redraw();
} else if !pressed
&& is_erasing
&& !tool_is_hardware_eraser
&& ui_state.active_tool != ActiveTool::Eraser
{
is_erasing = false;
}
}
octotablet::events::ToolEvent::Pose(pose) => {
is_stylus_in_proximity = true;
let sx = pose.position[0] * scale;
let sy = pose.position[1] * scale;
stylus_screen_pos = (sx, sy);
cursor_screen_pos = (sx, sy);
if let Some(p) = pose.pressure.get() {
current_pressure = p;
}
if is_erasing {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(
page_pt,
eraser_radius,
stroke_max_radius,
);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_page_dirty(
notebook.current_page(),
&manifest,
);
update_window_title(
&window,
&notebook,
&manifest,
ui_state.active_tool,
autosave.status(),
);
window.request_redraw();
}
}
} else if is_stylus_drawing && is_drawing {
let dx = sx - stroke_hold_anchor_pos.0;
let dy = sy - stroke_hold_anchor_pos.1;
if (dx * dx + dy * dy).sqrt() > 3.0 {
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (sx, sy);
}
if !stroke_already_snapped {
let page_pt = canvas.screen_to_page_clamped(sx, sy);
canvas.extend_stroke(
page_pt.x,
page_pt.y,
current_pressure,
now,
);
window.request_redraw();
}
}
}
octotablet::events::ToolEvent::Down => {
is_stylus_in_proximity = true;
stylus_tool_id = Some(tool.id());
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open
&& egui_ctx.is_pointer_over_area());
if is_stylus_in_ui {
egui_ctx.input_mut(|i| {
i.events.push(egui::Event::PointerMoved(egui::pos2(
sx / scale,
sy / scale,
)));
i.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
});
window.request_redraw();
continue;
}
let should_erase = tool_is_hardware_eraser
|| stylus_barrel_pressed
|| ui_state.active_tool == ActiveTool::Eraser;
if should_erase {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(
page_pt,
eraser_radius,
stroke_max_radius,
);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_page_dirty(
notebook.current_page(),
&manifest,
);
update_window_title(
&window,
&notebook,
&manifest,
ui_state.active_tool,
autosave.status(),
);
window.request_redraw();
}
}
} else if ui_state.active_tool.is_drawing()
|| ui_state.active_tool == ActiveTool::Shape
{
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
is_stylus_drawing = true;
is_drawing = true;
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (sx, sy);
stroke_already_snapped = false;
stroke_snapped_shape = None;
canvas.start_stroke(
page_pt.x,
page_pt.y,
current_pressure,
now,
);
window.request_redraw();
}
}
}
octotablet::events::ToolEvent::Up => {
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open
&& egui_ctx.is_pointer_over_area());
if is_stylus_in_ui {
egui_ctx.input_mut(|i| {
i.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
});
window.request_redraw();
continue;
}
if is_erasing {
is_erasing = false;
}
if is_stylus_drawing {
is_stylus_drawing = false;
is_drawing = false;
if !stroke_already_snapped {
let page_pt = canvas.screen_to_page_clamped(sx, sy);
canvas.extend_stroke(
page_pt.x,
page_pt.y,
current_pressure,
now,
);
}
let snapped_shape =
stroke_snapped_shape.take().or_else(|| {
if ui_state.active_tool == ActiveTool::Shape {
geometry::detect_shape(&canvas.live_samples)
} else {
None
}
});
commit_stroke_to_notebook(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&window,
snapped_shape,
);
}
}
_ => {}
}
}
}
}
}
// Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (< 3 px Distanz) // Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (< 3 px Distanz)
if is_drawing && !stroke_already_snapped { if is_drawing && !stroke_already_snapped {
let elapsed = stroke_last_motion_time.elapsed(); let elapsed = stroke_last_motion_time.elapsed();
@@ -221,13 +550,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Bei anstehenden Änderungen, aktivem Toast oder aktivem Zeichnen regelmäßig aufwachen // Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
if new_status == AutosaveStatus::Pending if new_status == AutosaveStatus::Pending
|| new_status == AutosaveStatus::Saving || new_status == AutosaveStatus::Saving
|| ui_state.export_message.is_some() || ui_state.export_message.is_some()
|| (is_drawing && !stroke_already_snapped) || (is_drawing && !stroke_already_snapped)
|| is_stylus_drawing
|| is_stylus_in_proximity
{ {
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(16))); target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else { } else {
target.set_control_flow(ControlFlow::Wait); target.set_control_flow(ControlFlow::Wait);
} }
@@ -263,7 +594,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
is_erasing = false; is_erasing = false;
is_mouse_drawing = false; is_mouse_drawing = false;
active_touch_id = None; active_touches.clear();
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
is_touch_drawing = false;
is_touch_panning = false;
is_stylus_drawing = false;
is_stylus_in_proximity = false;
} }
WindowEvent::Resized(physical_size) => { WindowEvent::Resized(physical_size) => {
@@ -672,8 +1010,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match state { match state {
ElementState::Pressed => { ElementState::Pressed => {
// Palm-Rejection: Keine Mausevents verarbeiten, wenn Touch aktiv ist // Palm-Rejection: Keine Mausevents verarbeiten, wenn Stylus aktiv zeichnet
if active_touch_id.is_some() { if is_stylus_drawing {
return; return;
} }
@@ -868,9 +1206,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let now = start_time.elapsed().as_secs_f64(); let now = start_time.elapsed().as_secs_f64();
canvas.extend_stroke(page_pt.x, page_pt.y, current_pressure, now); canvas.extend_stroke(page_pt.x, page_pt.y, current_pressure, now);
} }
let new_id = notebook.next_stroke_id();
// Prüfen, ob Form durch Draw-and-Hold gesnappt ist oder im Shape-Modus gezeichnet wurde
let snapped_shape = stroke_snapped_shape.take().or_else(|| { let snapped_shape = stroke_snapped_shape.take().or_else(|| {
if ui_state.active_tool == ActiveTool::Shape { if ui_state.active_tool == ActiveTool::Shape {
geometry::detect_shape(&canvas.live_samples) geometry::detect_shape(&canvas.live_samples)
@@ -879,24 +1214,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
let stroke_opt = if let Some(shape) = snapped_shape { commit_stroke_to_notebook(
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; &mut canvas,
let color = canvas.current_ink_color; &mut notebook,
canvas.cancel_stroke(); &mut manifest,
Some(geometry::shape_to_stroke(shape, new_id, color, stroke_width)) &mut autosave,
} else { &ui_state,
canvas.finish_stroke(new_id) &window,
}; snapped_shape,
);
if let Some(stroke) = stroke_opt {
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
} }
} }
} }
@@ -916,8 +1242,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return; return;
} }
// Palm-Rejection: Keine Cursor-Events verarbeiten, wenn Touch aktiv ist // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten
if active_touch_id.is_none() { if !is_stylus_drawing {
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
match &mut selection_state.drag_mode { match &mut selection_state.drag_mode {
@@ -1047,208 +1373,275 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser; let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
// Hardware-Palm-Rejection: Wenn der Stylus in Proximity ist oder zeichnet, Touch ignorieren
if is_stylus_drawing || is_stylus_in_proximity {
return;
}
match touch.phase { match touch.phase {
TouchPhase::Started => { TouchPhase::Started => {
// Palm-Rejection
if is_mouse_drawing || (active_touch_id.is_some() && active_touch_id != Some(touch.id)) {
return;
}
// Event-Isolation für Touch-Eingaben // Event-Isolation für Touch-Eingaben
let is_touch_in_ui = egui_ctx.wants_pointer_input() let is_touch_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(tx, ty) || ui_state.is_point_in_ui(tx, ty)
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area()) || (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area()); || (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
if is_touch_in_ui { if is_touch_in_ui {
return; return;
} }
active_touch_id = Some(touch.id); active_touches.insert(touch.id, (tx, ty));
current_pressure = pressure;
cursor_screen_pos = (tx, ty);
if is_eraser_action { if active_touches.len() >= 2 {
is_erasing = true; // Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if is_touch_drawing {
let page = notebook.current_page_mut(); is_touch_drawing = false;
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); is_drawing = false;
if !removed.is_empty() { canvas.cancel_stroke();
canvas.sync_page(page); window.request_redraw();
autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
} }
} else if ui_state.active_tool == ActiveTool::Select { is_touch_panning = false;
let screen_vec = Vec2::new(tx, ty); is_pinch_gesturing = true;
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
match hit { let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
SelectionHandleHit::RotateHandle { center_page } => { let p1 = pts[0];
let p2 = pts[1];
let center = ((p1.0 + p2.0) * 0.5, (p1.1 + p2.1) * 0.5);
let dist = ((p2.0 - p1.0).powi(2) + (p2.1 - p1.1).powi(2)).sqrt();
pinch_prev_center = Some(center);
pinch_prev_distance = Some(dist);
} else if active_touches.len() == 1 && !is_pinch_gesturing {
cursor_screen_pos = (tx, ty);
current_pressure = pressure;
if !ui_state.touch_draw_enabled {
// Standard: 1 Finger scrollt / verschiebt den Canvas (Pan)
is_touch_panning = true;
touch_pan_last_pos = (tx, ty);
} else {
// Modus Finger-Zeichnen aktiv
if is_eraser_action {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x); let page = notebook.current_page_mut();
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
selection_state.drag_mode = DragMode::Rotate { if !removed.is_empty() {
center_page, canvas.sync_page(page);
start_angle, autosave.mark_page_dirty(notebook.current_page(), &manifest);
initial_strokes, update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
};
window.request_redraw();
}
}
SelectionHandleHit::CornerHandle {
corner_idx: _,
fixed_corner_page,
start_pos_page,
initial_box_size,
} => {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
};
window.request_redraw();
}
SelectionHandleHit::Body => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
}
}
SelectionHandleHit::None => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) {
let stroke_id = stroke.id;
if !selection_state.selected_ids.contains(&stroke_id) {
selection_state.select_single(stroke_id);
}
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
} else {
selection_state.clear();
selection_state.drag_mode = DragMode::BoxSelect {
start_page: page_pt,
current_page: page_pt,
};
window.request_redraw(); window.request_redraw();
} }
} }
} else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Rotate {
center_page,
start_angle,
initial_strokes,
};
window.request_redraw();
}
}
SelectionHandleHit::CornerHandle {
corner_idx: _,
fixed_corner_page,
start_pos_page,
initial_box_size,
} => {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
};
window.request_redraw();
}
SelectionHandleHit::Body => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
}
}
SelectionHandleHit::None => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) {
let stroke_id = stroke.id;
if !selection_state.selected_ids.contains(&stroke_id) {
selection_state.select_single(stroke_id);
}
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
} else {
selection_state.clear();
selection_state.drag_mode = DragMode::BoxSelect {
start_page: page_pt,
current_page: page_pt,
};
window.request_redraw();
}
}
}
}
} else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
is_touch_drawing = true;
is_drawing = true;
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (tx, ty);
stroke_already_snapped = false;
stroke_snapped_shape = None;
canvas.start_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw();
}
} }
} }
} else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
is_drawing = true;
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (tx, ty);
stroke_already_snapped = false;
stroke_snapped_shape = None;
canvas.start_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw();
}
} }
} }
TouchPhase::Moved => { TouchPhase::Moved => {
if is_mouse_drawing || active_touch_id != Some(touch.id) { active_touches.insert(touch.id, (tx, ty));
return;
}
current_pressure = pressure; if active_touches.len() >= 2 {
cursor_screen_pos = (tx, ty); let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
let p1 = pts[0];
let p2 = pts[1];
let curr_center = ((p1.0 + p2.0) * 0.5, (p1.1 + p2.1) * 0.5);
let curr_dist = ((p2.0 - p1.0).powi(2) + (p2.1 - p1.1).powi(2)).sqrt();
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if let (Some(prev_dist), Some(prev_center)) = (pinch_prev_distance, pinch_prev_center) {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { let dx = curr_center.0 - prev_center.0;
match &mut selection_state.drag_mode { let dy = curr_center.1 - prev_center.1;
DragMode::BoxSelect { current_page, .. } => { if dx.abs() > 0.05 || dy.abs() > 0.05 {
*current_page = page_pt; canvas.pan(dx, dy);
window.request_redraw();
}
DragMode::Move { start_page, initial_strokes } => {
let delta = page_pt - *start_page;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.translate(delta);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Rotate { center_page, start_angle, initial_strokes } => {
let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let delta_angle = current_angle - *start_angle;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.rotate(*center_page, delta_angle);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => {
let orig_delta = *start_pos_page - *fixed_corner_page;
let new_delta = page_pt - *fixed_corner_page;
let scale_x = if orig_delta.x.abs() > 1.0 {
(new_delta.x / orig_delta.x).clamp(0.05, 20.0)
} else {
1.0
};
let scale_y = if orig_delta.y.abs() > 1.0 {
(new_delta.y / orig_delta.y).clamp(0.05, 20.0)
} else {
1.0
};
let factors = Vec2::new(scale_x, scale_y);
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.scale(*fixed_corner_page, factors);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::None => {}
} }
if prev_dist > 5.0 && curr_dist > 5.0 {
let zoom_factor = curr_dist / prev_dist;
if (zoom_factor - 1.0).abs() > 0.001 {
canvas.zoom_at(curr_center.0, curr_center.1, zoom_factor);
ui_state.current_zoom = canvas.camera.zoom;
}
}
window.request_redraw();
} }
} else if is_erasing { pinch_prev_center = Some(curr_center);
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { pinch_prev_distance = Some(curr_dist);
let page = notebook.current_page_mut(); } else if active_touches.len() == 1 && !is_pinch_gesturing {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); if is_touch_panning {
if !removed.is_empty() { let dx = tx - touch_pan_last_pos.0;
canvas.sync_page(page); let dy = ty - touch_pan_last_pos.1;
autosave.mark_page_dirty(notebook.current_page(), &manifest); touch_pan_last_pos = (tx, ty);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); canvas.pan(dx, dy);
window.request_redraw();
} else if is_touch_drawing {
current_pressure = pressure;
cursor_screen_pos = (tx, ty);
let dx = tx - stroke_hold_anchor_pos.0;
let dy = ty - stroke_hold_anchor_pos.1;
if (dx * dx + dy * dy).sqrt() > 3.0 {
stroke_last_motion_time = Instant::now();
stroke_hold_anchor_pos = (tx, ty);
}
if !stroke_already_snapped {
let page_pt = canvas.screen_to_page_clamped(tx, ty);
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw(); window.request_redraw();
} }
} } else if is_erasing {
} else if is_drawing { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let dx = tx - stroke_hold_anchor_pos.0; let page = notebook.current_page_mut();
let dy = ty - stroke_hold_anchor_pos.1; let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if (dx * dx + dy * dy).sqrt() > 3.0 { if !removed.is_empty() {
stroke_last_motion_time = Instant::now(); canvas.sync_page(page);
stroke_hold_anchor_pos = (tx, ty); autosave.mark_page_dirty(notebook.current_page(), &manifest);
} update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
if !stroke_already_snapped { }
let page_pt = canvas.screen_to_page_clamped(tx, ty); }
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
window.request_redraw(); if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt;
window.request_redraw();
}
DragMode::Move { start_page, initial_strokes } => {
let delta = page_pt - *start_page;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.translate(delta);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Rotate { center_page, start_angle, initial_strokes } => {
let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let delta_angle = current_angle - *start_angle;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.rotate(*center_page, delta_angle);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => {
let orig_delta = *start_pos_page - *fixed_corner_page;
let new_delta = page_pt - *fixed_corner_page;
let scale_x = if orig_delta.x.abs() > 1.0 {
(new_delta.x / orig_delta.x).clamp(0.05, 20.0)
} else {
1.0
};
let scale_y = if orig_delta.y.abs() > 1.0 {
(new_delta.y / orig_delta.y).clamp(0.05, 20.0)
} else {
1.0
};
let factors = Vec2::new(scale_x, scale_y);
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.scale(*fixed_corner_page, factors);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::None => {}
}
}
} }
} }
} }
TouchPhase::Ended => { TouchPhase::Ended | TouchPhase::Cancelled => {
if active_touch_id == Some(touch.id) { active_touches.remove(&touch.id);
active_touch_id = None;
if active_touches.is_empty() {
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
is_touch_panning = false;
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if ui_state.active_tool == ActiveTool::Select { if ui_state.active_tool == ActiveTool::Select {
match std::mem::take(&mut selection_state.drag_mode) { match std::mem::take(&mut selection_state.drag_mode) {
DragMode::BoxSelect { start_page, current_page } => { DragMode::BoxSelect { start_page, current_page } => {
@@ -1273,14 +1666,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
DragMode::None => {} DragMode::None => {}
} }
} }
if is_drawing {
if is_touch_drawing {
is_touch_drawing = false;
is_drawing = false; is_drawing = false;
if !stroke_already_snapped { if !stroke_already_snapped {
let page_pt = canvas.screen_to_page_clamped(tx, ty); let page_pt = canvas.screen_to_page_clamped(tx, ty);
let now = start_time.elapsed().as_secs_f64();
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now);
} }
let new_id = notebook.next_stroke_id();
let snapped_shape = stroke_snapped_shape.take().or_else(|| { let snapped_shape = stroke_snapped_shape.take().or_else(|| {
if ui_state.active_tool == ActiveTool::Shape { if ui_state.active_tool == ActiveTool::Shape {
@@ -1290,37 +1684,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
let stroke_opt = if let Some(shape) = snapped_shape { commit_stroke_to_notebook(
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; &mut canvas,
let color = canvas.current_ink_color; &mut notebook,
canvas.cancel_stroke(); &mut manifest,
Some(geometry::shape_to_stroke(shape, new_id, color, stroke_width)) &mut autosave,
} else { &ui_state,
canvas.finish_stroke(new_id) &window,
}; snapped_shape,
);
if let Some(stroke) = stroke_opt {
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
} }
} } else if active_touches.len() == 1 {
} pinch_prev_distance = None;
TouchPhase::Cancelled => { pinch_prev_center = None;
if active_touch_id == Some(touch.id) { let rem = *active_touches.values().next().unwrap();
active_touch_id = None; touch_pan_last_pos = rem;
selection_state.drag_mode = DragMode::None;
if is_drawing {
is_drawing = false;
canvas.cancel_stroke();
window.request_redraw();
}
is_erasing = false;
} }
} }
} }
+25 -1
View File
@@ -160,6 +160,8 @@ pub struct UiState {
pub updater_token_input: String, pub updater_token_input: String,
pub show_updater_config: bool, pub show_updater_config: bool,
pub update_modal_rect: Option<egui::Rect>, pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool,
} }
impl Default for UiState { impl Default for UiState {
@@ -190,6 +192,7 @@ impl Default for UiState {
updater_token_input: String::new(), updater_token_input: String::new(),
show_updater_config: false, show_updater_config: false,
update_modal_rect: None, update_modal_rect: None,
touch_draw_enabled: false,
} }
} }
} }
@@ -950,7 +953,19 @@ pub fn render_ui(
toolbar_divider(ui); toolbar_divider(ui);
// --- 9. AUTOSAVE-STATUSANZEIGE --- // --- 9. TOUCH-MODUS UMSCHALTER (Gesten vs. Zeichnen) ---
let (touch_icon, touch_tt) = if state.touch_draw_enabled {
("✏️ Touch: Stift", "Touch-Modus: Einzelfinger zeichnet, zwei Finger zoomen/verschieben. Klicken zum Umschalten auf reine Gesten-Navigation.")
} else {
("🖐️ Touch: Zoom/Pan", "Touch-Modus: Finger zoomen und verschieben die Seite (ideal für Stifte wie Lazarite!). Klicken für Finger-Zeichnen.")
};
if action_pill_button(ui, true, touch_icon, touch_tt).clicked() {
state.touch_draw_enabled = !state.touch_draw_enabled;
}
toolbar_divider(ui);
// --- 10. AUTOSAVE-STATUSANZEIGE ---
let (dot_color, label_text) = match autosave_status { let (dot_color, label_text) = match autosave_status {
AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"), AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"),
AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert..."), AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert..."),
@@ -2083,4 +2098,13 @@ mod tests {
assert!(state.is_point_in_ui(rect.center().x, rect.center().y)); assert!(state.is_point_in_ui(rect.center().x, rect.center().y));
} }
} }
#[test]
fn test_touch_draw_mode_toggle() {
let mut state = UiState::new();
assert!(!state.touch_draw_enabled); // Default: false = Gesten-Navigation & Zoom
state.touch_draw_enabled = true;
assert!(state.touch_draw_enabled);
}
} }
+36
View File
@@ -246,4 +246,40 @@ mod tests {
assert!((offset1[0] - offset2[0]).abs() < 1e-5); assert!((offset1[0] - offset2[0]).abs() < 1e-5);
assert!((offset1[1] - offset2[1]).abs() < 1e-5); assert!((offset1[1] - offset2[1]).abs() < 1e-5);
} }
#[test]
fn test_pinch_to_zoom_math() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
// Simulation von 2 Fingern:
// Finger 1 bei (400, 500), Finger 2 bei (600, 500) -> Distanz: 200, Zentrum: (500, 500)
let p1_start = (400.0_f32, 500.0_f32);
let p2_start = (600.0_f32, 500.0_f32);
let dist_start =
((p2_start.0 - p1_start.0).powi(2) + (p2_start.1 - p1_start.1).powi(2)).sqrt();
let center_start = (
(p1_start.0 + p2_start.0) * 0.5,
(p1_start.1 + p2_start.1) * 0.5,
);
assert_eq!(dist_start, 200.0);
assert_eq!(center_start, (500.0, 500.0));
// Finger auseinanderziehen und leicht verschieben:
// Finger 1 bei (350, 520), Finger 2 bei (650, 520) -> Distanz: 300, Zentrum: (500, 520)
let p1_end = (350.0_f32, 520.0_f32);
let p2_end = (650.0_f32, 520.0_f32);
let dist_end = ((p2_end.0 - p1_end.0).powi(2) + (p2_end.1 - p1_end.1).powi(2)).sqrt();
let center_end = ((p1_end.0 + p2_end.0) * 0.5, (p1_end.1 + p2_end.1) * 0.5);
assert_eq!(dist_end, 300.0);
assert_eq!(center_end, (500.0, 520.0));
let zoom_factor = dist_end / dist_start; // 1.5x Vergrößerung
let pan_delta = (center_end.0 - center_start.0, center_end.1 - center_start.1); // (0, +20)
vp.pan_by_screen_delta(pan_delta.0, pan_delta.1, &page_vp);
vp.zoom_at_cursor([center_end.0, center_end.1], zoom_factor, &page_vp);
assert!((vp.zoom - 1.5).abs() < 1e-4);
}
} }