diff --git a/Cargo.lock b/Cargo.lock index 04da334..cc43663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,9 @@ dependencies = [ "tempfile", "ureq", "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", "webbrowser", "wgpu", "winit", diff --git a/Cargo.toml b/Cargo.toml index 7b629d0..cb6c097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,11 @@ hayro-svg = "0.8" # Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui) ab_glyph = "0.2" +# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs) +wayland-client = "0.31" +wayland-backend = { version = "0.3", features = ["client_system"] } +wayland-protocols = { version = "0.31", features = ["client", "unstable"] } + [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/lib.rs b/src/lib.rs index c0d18fa..f89b190 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod geometry; pub mod history; pub mod image_layer; pub mod library; +pub mod osk; pub mod page_export; pub mod pdf_doc; pub mod pdf_merge; diff --git a/src/main.rs b/src/main.rs index 0e56934..fb77b14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use winit::{ event_loop::{ControlFlow, EventLoopBuilder}, keyboard::{Key, ModifiersState, NamedKey}, platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11}, + raw_window_handle::{HasDisplayHandle, RawDisplayHandle}, window::{Window, WindowBuilder}, }; @@ -25,6 +26,7 @@ use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::image_layer::TextureSource; use ink_notes::library::Library; +use ink_notes::osk::{self, KeyboardRequester}; use ink_notes::pdf_doc::PdfRenderService; use ink_notes::platform::{self, DisplayBackend}; use ink_notes::selection::{ @@ -1148,6 +1150,17 @@ fn main() -> Result<(), Box> { } }; + // Bildschirmtastatur (nur Wayland): eigenes text-input-Objekt, siehe osk.rs + let mut keyboard_requester = match window.display_handle().map(|handle| handle.as_raw()) { + // Safety: `window` lebt bis zum Ende von main, die Event-Schleife besitzt beide + Ok(RawDisplayHandle::Wayland(handle)) => { + Some(unsafe { KeyboardRequester::new(handle.display.as_ptr()) }) + } + _ => None, + }; + let mut ime_was_active = false; + let mut ime_composing = false; + // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) let storage_base_dir = storage::default_storage_dir(); let _ = storage::cleanup_trash( @@ -1307,6 +1320,8 @@ fn main() -> Result<(), Box> { let mut pinch_prev_distance: Option = None; // Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui let mut ui_touch_ids: std::collections::HashSet = std::collections::HashSet::new(); + // Alle aufliegenden Finger (auch auf der UI), unabhängig von der Gestenauswertung + let mut touches_down: std::collections::HashSet = std::collections::HashSet::new(); let mut pinch_prev_center: Option<(f32, f32)> = None; let mut is_pinch_gesturing = false; let mut is_touch_drawing = false; @@ -1869,7 +1884,30 @@ fn main() -> Result<(), Box> { Event::WindowEvent { event, window_id } if window_id == window.id() => { // 1. Window-Event zuerst an egui übergeben - let egui_response = egui_winit_state.on_window_event(&window, &event); + if let WindowEvent::Touch(touch) = &event { + match touch.phase { + TouchPhase::Started => { + touches_down.insert(touch.id); + } + TouchPhase::Ended | TouchPhase::Cancelled => { + touches_down.remove(&touch.id); + } + TouchPhase::Moved => {} + } + } + // Beim Antippen blendet GNOME den Mauszeiger aus und meldet CursorLeft, während der + // Finger noch aufliegt. egui-winit verwirft dabei die Zeigerposition und meldet das + // Abheben nicht mehr, der Tipp ginge verloren – daher erfährt egui davon nichts. + let egui_response = if matches!(event, WindowEvent::CursorLeft { .. }) + && !touches_down.is_empty() + { + egui_winit::EventResponse { + repaint: true, + consumed: false, + } + } else { + egui_winit_state.on_window_event(&window, &event) + }; if egui_response.repaint { window.request_redraw(); } @@ -2938,7 +2976,11 @@ fn main() -> Result<(), Box> { } // 1. egui Eingabe erfassen und Frame starten - let raw_input = egui_winit_state.take_egui_input(&window); + let mut raw_input = egui_winit_state.take_egui_input(&window); + osk::plain_ime_commits_to_text(&mut raw_input.events, &mut ime_composing); + if let Some(requester) = keyboard_requester.as_mut() { + raw_input.events.extend(requester.take_deletions()); + } egui_ctx.begin_frame(raw_input); // 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso) @@ -3702,7 +3744,15 @@ fn main() -> Result<(), Box> { // 4. egui Frame beenden & Primitives tessellieren let full_output = egui_ctx.end_frame(); + let ime_active = full_output.platform_output.ime.is_some(); egui_winit_state.handle_platform_output(&window, full_output.platform_output); + if let Some(requester) = keyboard_requester.as_mut() { + let pressed = egui_ctx.input(|i| i.pointer.any_pressed()); + if osk::should_request(ime_was_active, ime_active, pressed) { + requester.request(); + } + } + ime_was_active = ime_active; // egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen // (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert) diff --git a/src/osk.rs b/src/osk.rs new file mode 100644 index 0000000..6cdffde --- /dev/null +++ b/src/osk.rs @@ -0,0 +1,294 @@ +//! Bildschirmtastatur unter Wayland anfordern. +//! +//! GNOME (mutter) blendet die Bildschirmtastatur nicht schon beim ersten `enable` eines +//! Texteingabefelds (`zwp_text_input_v3`) ein, sondern erst, wenn ein bereits aktives Feld +//! `enable` erneut meldet – so macht es GTK beim Antippen. winit meldet `enable` nur einmal, +//! sobald egui ein Textfeld fokussiert. Daher schickt ink-notes über ein eigenes +//! text-input-Objekt auf derselben Wayland-Verbindung ein weiteres `enable`. Die getippten +//! Zeichen kommen weiterhin über winit an. +//! +//! Die Rücktaste der GNOME-Bildschirmtastatur löscht über `delete_surrounding_text` im zuvor +//! gemeldeten Umgebungstext; ohne Umgebungstext passiert nichts, und winit wertet das Löschen +//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und +//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui. + +use std::ffi::c_void; + +use wayland_client::{ + delegate_noop, + protocol::{wl_registry, wl_seat}, + Connection, Dispatch, EventQueue, QueueHandle, +}; +use wayland_protocols::wp::text_input::zv3::client::{ + zwp_text_input_manager_v3::ZwpTextInputManagerV3, + zwp_text_input_v3::{self, ZwpTextInputV3}, +}; + +/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der +/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen) +const SURROUNDING_PLACEHOLDER: &str = " "; + +/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um. +/// +/// Die Bildschirmtastatur schickt jedes Zeichen als fertigen IME-Text, ohne vorher eine Vorschau +/// zu melden. egui 0.27 fügt solche Texte nur ein, solange die Schreibmarke dort steht, wo die +/// letzte Vorschau begann – also nur das erste Zeichen. `composing` merkt sich über Frames hinweg, +/// ob gerade eine Vorschau läuft; deren Abschluss bleibt bei egui. +pub fn plain_ime_commits_to_text(events: &mut [egui::Event], composing: &mut bool) { + for event in events { + match event { + egui::Event::CompositionStart => *composing = true, + egui::Event::CompositionEnd(text) => { + if !*composing && !text.is_empty() && !text.contains(['\n', '\r']) { + *event = egui::Event::Text(std::mem::take(text)); + } + *composing = false; + } + _ => {} + } + } +} + +/// Tastendrücke für egui, die `back` Zeichen vor und `forward` Zeichen nach der Schreibmarke +/// löschen +pub fn deletion_key_events(back: u32, forward: u32) -> Vec { + let presses = std::iter::repeat_n(egui::Key::Backspace, back as usize) + .chain(std::iter::repeat_n(egui::Key::Delete, forward as usize)); + presses + .flat_map(|key| { + [true, false].map(|pressed| egui::Event::Key { + key, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::NONE, + }) + }) + .collect() +} + +/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus +/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen +/// zugeklappt wurde). +pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool { + active && (!was_active || pressed) +} + +#[derive(Default)] +struct State { + seat: Option, + manager: Option, + text_input: Option, + /// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke) + delete_back: u32, + delete_forward: u32, +} + +/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert +pub struct KeyboardRequester { + conn: Connection, + queue: EventQueue, + state: State, +} + +impl KeyboardRequester { + /// Hängt sich an die Wayland-Verbindung des Fensters. + /// + /// # Safety + /// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange + /// der `KeyboardRequester` lebt. + pub unsafe fn new(wl_display: *mut c_void) -> Self { + // Safety: an den Aufrufer weitergereicht + let backend = + unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) }; + let conn = Connection::from_backend(backend); + let queue = conn.new_event_queue(); + conn.display().get_registry(&queue.handle(), ()); + let _ = conn.flush(); + Self { + conn, + queue, + state: State::default(), + } + } + + /// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen. + pub fn pump(&mut self) { + let _ = self.queue.dispatch_pending(&mut self.state); + if self.state.text_input.is_none() { + if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) { + self.state.text_input = + Some(manager.get_text_input(seat, &self.queue.handle(), ())); + } + } + } + + /// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von + /// winit kommen (also nach `handle_platform_output`). + pub fn request(&mut self) { + self.pump(); + if let Some(text_input) = &self.state.text_input { + text_input.enable(); + self.send_surrounding_placeholder(); + } + } + + /// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame` + /// aufrufen und an die egui-Eingabe anhängen) + pub fn take_deletions(&mut self) -> Vec { + self.pump(); + // Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung + // erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt. + let back = std::mem::take(&mut self.state.delete_back); + let forward = std::mem::take(&mut self.state.delete_forward); + deletion_key_events(back, forward) + } + + fn send_surrounding_placeholder(&self) { + if let Some(text_input) = &self.state.text_input { + let len = SURROUNDING_PLACEHOLDER.len() as i32; + text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len); + text_input.commit(); + let _ = self.conn.flush(); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, interface, .. + } = event + { + match interface.as_str() { + "wl_seat" if state.seat.is_none() => { + state.seat = Some(registry.bind(name, 1, qh, ())); + } + "zwp_text_input_manager_v3" if state.manager.is_none() => { + state.manager = Some(registry.bind(name, 1, qh, ())); + } + _ => {} + } + } + } +} + +delegate_noop!(State: ignore wl_seat::WlSeat); +delegate_noop!(State: ZwpTextInputManagerV3); +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZwpTextInputV3, + event: zwp_text_input_v3::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // Getippter Text kommt über winit; hier nur das Löschen + if let zwp_text_input_v3::Event::DeleteSurroundingText { + before_length, + after_length, + } = event + { + state.delete_back += before_length; + state.delete_forward += after_length; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_when_text_field_gets_focus() { + assert!(should_request(false, true, false)); + assert!(should_request(false, true, true)); + } + + #[test] + fn requests_again_when_focused_field_is_tapped() { + assert!(should_request(true, true, true)); + assert!(!should_request(true, true, false)); + } + + #[test] + fn commits_without_preedit_become_text() { + let mut composing = false; + let mut events = vec![ + egui::Event::CompositionEnd("h".into()), + egui::Event::CompositionEnd("i".into()), + ]; + plain_ime_commits_to_text(&mut events, &mut composing); + assert_eq!( + events, + vec![egui::Event::Text("h".into()), egui::Event::Text("i".into())] + ); + assert!(!composing); + } + + #[test] + fn commits_after_preedit_stay_with_egui() { + let mut composing = false; + let mut events = vec![ + egui::Event::CompositionStart, + egui::Event::CompositionUpdate("ni".into()), + ]; + plain_ime_commits_to_text(&mut events, &mut composing); + assert!(composing); + // Abschluss erst im nächsten Frame + let mut events = vec![egui::Event::CompositionEnd("你".into())]; + plain_ime_commits_to_text(&mut events, &mut composing); + assert_eq!(events, vec![egui::Event::CompositionEnd("你".into())]); + assert!(!composing); + } + + #[test] + fn line_breaks_and_empty_commits_are_left_alone() { + let mut composing = false; + let mut events = vec![ + egui::Event::CompositionEnd("\n".into()), + egui::Event::CompositionEnd(String::new()), + ]; + let before = events.clone(); + plain_ime_commits_to_text(&mut events, &mut composing); + assert_eq!(events, before); + } + + #[test] + fn deletions_become_key_presses() { + let events = deletion_key_events(2, 1); + let keys: Vec<_> = events + .iter() + .map(|e| match e { + egui::Event::Key { key, pressed, .. } => (*key, *pressed), + other => panic!("unerwartet: {other:?}"), + }) + .collect(); + assert_eq!( + keys, + vec![ + (egui::Key::Backspace, true), + (egui::Key::Backspace, false), + (egui::Key::Backspace, true), + (egui::Key::Backspace, false), + (egui::Key::Delete, true), + (egui::Key::Delete, false), + ] + ); + assert!(deletion_key_events(0, 0).is_empty()); + } + + #[test] + fn no_request_without_focused_text_field() { + assert!(!should_request(false, false, true)); + assert!(!should_request(true, false, true)); + } +}