Files
ink-notes/src/osk.rs
T
haraldandClaude Opus 5.5 6fb190cf79 fix: Bildschirmtastatur unter GNOME/Wayland (Issue #21)
GNOME blendet die Bildschirmtastatur erst ein, wenn ein bereits aktives
Textfeld zwp_text_input_v3.enable erneut meldet (wie GTK beim Antippen).
winit meldet enable nur einmal. Ein eigenes text-input-Objekt auf
derselben Wayland-Verbindung schickt das zweite enable, wenn ein Textfeld
den Fokus bekommt oder erneut angetippt wird.

- Zeichen der Bildschirmtastatur kommen als IME-Text ohne Vorschau; egui
  0.27 fügte davon nur das erste ein. Sie werden jetzt als normaler Text
  übergeben.
- Die Rücktaste löscht über delete_surrounding_text; dafür meldet das
  eigene Objekt einen Platzhalter-Umgebungstext und übersetzt das Löschen
  in Tastendrücke.
- Ein Fingertipp ging verloren, wenn GNOME beim Antippen den Mauszeiger
  ausblendet (CursorLeft bei aufliegendem Finger); das Ereignis erreicht
  egui in dem Fall nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:11:45 +02:00

295 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<egui::Event> {
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<wl_seat::WlSeat>,
manager: Option<ZwpTextInputManagerV3>,
text_input: Option<ZwpTextInputV3>,
/// 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: 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<egui::Event> {
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<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<ZwpTextInputV3, ()> for State {
fn event(
state: &mut Self,
_: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
// 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));
}
}