From df72981a9a2b9d317205a32275d6ed1e8a9ef115 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 14:58:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(input):=20Wayland-Tablet-Protokoll=20f?= =?UTF-8?q?=C3=BCr=20aktive=20Stifte=20(Lazarite)=20und=20Touchscreen-Pinc?= =?UTF-8?q?h-to-Zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 131 +++++++- Cargo.toml | 1 + src/main.rs | 840 +++++++++++++++++++++++++++++++++++------------- src/ui.rs | 26 +- src/viewport.rs | 36 +++ 5 files changed, 796 insertions(+), 238 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f61684..d6c9cc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,6 +284,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -573,6 +579,18 @@ dependencies = [ "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]] name = "epaint" version = "0.27.2" @@ -812,7 +830,7 @@ dependencies = [ "presser", "thiserror", "winapi", - "windows", + "windows 0.52.0", ] [[package]] @@ -866,6 +884,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1021,6 +1045,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "glam", + "octotablet", "pollster", "printpdf", "semver", @@ -1418,6 +1443,25 @@ dependencies = [ "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]] name = "once_cell" version = "1.21.4" @@ -1965,6 +2009,28 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "subtle" version = "2.6.1" @@ -2519,7 +2585,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" dependencies = [ "arrayvec", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "js-sys", "log", "naga", @@ -2545,7 +2611,7 @@ dependencies = [ "arrayvec", "bit-vec", "bitflags 2.13.2", - "cfg_aliases", + "cfg_aliases 0.1.1", "codespan-reporting", "indexmap", "log", @@ -2574,7 +2640,7 @@ dependencies = [ "bit-set", "bitflags 2.13.2", "block", - "cfg_aliases", + "cfg_aliases 0.1.1", "core-graphics-types", "d3d12", "glow", @@ -2661,7 +2727,19 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" 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", ] @@ -2674,12 +2752,53 @@ dependencies = [ "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]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-sys" version = "0.45.0" @@ -2915,7 +3034,7 @@ dependencies = [ "bitflags 2.13.2", "bytemuck", "calloop", - "cfg_aliases", + "cfg_aliases 0.1.1", "core-foundation", "core-graphics", "cursor-icon", diff --git a/Cargo.toml b/Cargo.toml index c4d928e..a8b3903 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ semver = "1.0" sha2 = "0.10" ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } url = "2.5" +octotablet = "0.1.0" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/main.rs b/src/main.rs index 76c5736..b3580e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,49 @@ fn update_window_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, +) { + 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> { let event_loop = EventLoop::new()?; let window = Arc::new( @@ -63,6 +106,17 @@ fn main() -> Result<(), Box> { 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) let storage_base_dir = storage::default_storage_dir(); let available_notebooks = storage::list_notebooks(&storage_base_dir); @@ -135,7 +189,23 @@ fn main() -> Result<(), Box> { let mut modifiers = ModifiersState::empty(); let mut is_mouse_drawing = false; - let mut active_touch_id: Option = None; + + // Multi-Touch & Pinch-to-Zoom Zustand + let mut active_touches: std::collections::HashMap = + std::collections::HashMap::new(); + let mut pinch_prev_distance: Option = 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 = None; + let mut stylus_barrel_pressed = false; + let mut stylus_screen_pos = (0.0_f32, 0.0_f32); // Viewport-Pan & Zoom-Zustand let mut is_panning = false; @@ -201,6 +271,265 @@ fn main() -> Result<(), Box> { 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, + ¬ebook, + &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, + ¬ebook, + &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) if is_drawing && !stroke_already_snapped { let elapsed = stroke_last_motion_time.elapsed(); @@ -221,13 +550,15 @@ fn main() -> Result<(), Box> { } } - // 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 || new_status == AutosaveStatus::Saving || ui_state.export_message.is_some() || (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 { target.set_control_flow(ControlFlow::Wait); } @@ -263,7 +594,14 @@ fn main() -> Result<(), Box> { } is_erasing = 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) => { @@ -672,8 +1010,8 @@ fn main() -> Result<(), Box> { match state { ElementState::Pressed => { - // Palm-Rejection: Keine Mausevents verarbeiten, wenn Touch aktiv ist - if active_touch_id.is_some() { + // Palm-Rejection: Keine Mausevents verarbeiten, wenn Stylus aktiv zeichnet + if is_stylus_drawing { return; } @@ -868,9 +1206,6 @@ fn main() -> Result<(), Box> { let now = start_time.elapsed().as_secs_f64(); 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(|| { if ui_state.active_tool == ActiveTool::Shape { geometry::detect_shape(&canvas.live_samples) @@ -879,24 +1214,15 @@ fn main() -> Result<(), Box> { } }); - 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - window.request_redraw(); - } + commit_stroke_to_notebook( + &mut canvas, + &mut notebook, + &mut manifest, + &mut autosave, + &ui_state, + &window, + snapped_shape, + ); } } } @@ -916,8 +1242,8 @@ fn main() -> Result<(), Box> { return; } - // Palm-Rejection: Keine Cursor-Events verarbeiten, wenn Touch aktiv ist - if active_touch_id.is_none() { + // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten + if !is_stylus_drawing { if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if let Some(page_pt) = canvas.screen_to_page(sx, sy) { match &mut selection_state.drag_mode { @@ -1047,208 +1373,275 @@ fn main() -> Result<(), Box> { 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 { 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 let is_touch_in_ui = egui_ctx.wants_pointer_input() || ui_state.is_point_in_ui(tx, ty) || (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 { return; } - active_touch_id = Some(touch.id); - current_pressure = pressure; - cursor_screen_pos = (tx, ty); + active_touches.insert(touch.id, (tx, ty)); - if is_eraser_action { - is_erasing = true; - if let Some(page_pt) = canvas.screen_to_page(tx, ty) { - 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - window.request_redraw(); - } + if active_touches.len() >= 2 { + // Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen + if is_touch_drawing { + is_touch_drawing = false; + is_drawing = false; + canvas.cancel_stroke(); + 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 } => { + is_touch_panning = false; + is_pinch_gesturing = true; + + let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); + 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) { - 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, - }; + 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); 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 => { - if is_mouse_drawing || active_touch_id != Some(touch.id) { - return; - } + active_touches.insert(touch.id, (tx, ty)); - current_pressure = pressure; - cursor_screen_pos = (tx, ty); + if active_touches.len() >= 2 { + 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(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 => {} + if let (Some(prev_dist), Some(prev_center)) = (pinch_prev_distance, pinch_prev_center) { + let dx = curr_center.0 - prev_center.0; + let dy = curr_center.1 - prev_center.1; + if dx.abs() > 0.05 || dy.abs() > 0.05 { + canvas.pan(dx, dy); } + 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 { - if let Some(page_pt) = canvas.screen_to_page(tx, ty) { - 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + pinch_prev_center = Some(curr_center); + pinch_prev_distance = Some(curr_dist); + } else if active_touches.len() == 1 && !is_pinch_gesturing { + if is_touch_panning { + let dx = tx - touch_pan_last_pos.0; + let dy = ty - touch_pan_last_pos.1; + touch_pan_last_pos = (tx, ty); + 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(); } - } - } else if is_drawing { - 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(); + } else if is_erasing { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { + 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 => { - if active_touch_id == Some(touch.id) { - active_touch_id = None; + TouchPhase::Ended | TouchPhase::Cancelled => { + active_touches.remove(&touch.id); + + if active_touches.is_empty() { + is_pinch_gesturing = false; + pinch_prev_distance = None; + pinch_prev_center = None; + is_touch_panning = false; + if is_erasing { is_erasing = false; } + if ui_state.active_tool == ActiveTool::Select { match std::mem::take(&mut selection_state.drag_mode) { DragMode::BoxSelect { start_page, current_page } => { @@ -1273,14 +1666,15 @@ fn main() -> Result<(), Box> { DragMode::None => {} } } - if is_drawing { + + if is_touch_drawing { + is_touch_drawing = false; is_drawing = false; + if !stroke_already_snapped { 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); } - let new_id = notebook.next_stroke_id(); let snapped_shape = stroke_snapped_shape.take().or_else(|| { if ui_state.active_tool == ActiveTool::Shape { @@ -1290,37 +1684,21 @@ fn main() -> Result<(), Box> { } }); - 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, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - window.request_redraw(); - } + commit_stroke_to_notebook( + &mut canvas, + &mut notebook, + &mut manifest, + &mut autosave, + &ui_state, + &window, + snapped_shape, + ); } - } - } - TouchPhase::Cancelled => { - if active_touch_id == Some(touch.id) { - active_touch_id = None; - selection_state.drag_mode = DragMode::None; - if is_drawing { - is_drawing = false; - canvas.cancel_stroke(); - window.request_redraw(); - } - is_erasing = false; + } else if active_touches.len() == 1 { + pinch_prev_distance = None; + pinch_prev_center = None; + let rem = *active_touches.values().next().unwrap(); + touch_pan_last_pos = rem; } } } diff --git a/src/ui.rs b/src/ui.rs index 3d50835..c2911c7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -160,6 +160,8 @@ pub struct UiState { pub updater_token_input: String, pub show_updater_config: bool, pub update_modal_rect: Option, + /// 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 { @@ -190,6 +192,7 @@ impl Default for UiState { updater_token_input: String::new(), show_updater_config: false, update_modal_rect: None, + touch_draw_enabled: false, } } } @@ -950,7 +953,19 @@ pub fn render_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 { AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"), 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)); } } + + #[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); + } } diff --git a/src/viewport.rs b/src/viewport.rs index 668c7f4..33d748e 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -246,4 +246,40 @@ mod tests { assert!((offset1[0] - offset2[0]).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); + } }