Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6a5259d5 | ||
|
|
c92bf16cbf | ||
|
|
57028dfbc6 | ||
|
|
6fb190cf79 |
No files matched your search
Generated
+4
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.22.1"
|
version = "0.24.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -1418,6 +1418,9 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.22.1"
|
version = "0.24.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
@@ -51,6 +51,11 @@ hayro-svg = "0.8"
|
|||||||
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
ab_glyph = "0.2"
|
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]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Achsenkreuz aus zwei Strichen**: Eine waagerechte und eine senkrechte Gerade über Kreuz zeichnen – die zweite mit Halten oder dem Formen-Werkzeug – und beide werden zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; Rückgängig stellt die erste Gerade wieder her
|
||||||
|
- **Bildschirmtastatur**: Unter GNOME/Zorin (Wayland) erscheint die Bildschirmtastatur jetzt beim Antippen eines Namensfelds – mit Finger oder Stift; Tippen und Rücktaste funktionieren. Außerdem geht der erste Fingertipp nach Mausbenutzung nicht mehr verloren
|
||||||
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
|
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
|
||||||
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
|
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
|
||||||
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
|
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
|
||||||
|
|||||||
+110
@@ -1499,6 +1499,77 @@ pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz aus zwei Strichen: eine waagerechte und eine senkrechte Gerade (je höchstens
|
||||||
|
/// 25° schief, Reihenfolge egal), die sich kreuzen und auf beiden Seiten des Schnittpunkts
|
||||||
|
/// deutlich weiterlaufen. Ergebnis wie bei `detect_axis_cross`.
|
||||||
|
pub fn axis_cross_from_lines(a: (Vec2, Vec2), b: (Vec2, Vec2)) -> Option<RecognizedShape> {
|
||||||
|
let max_slope = 25.0_f32.to_radians().tan();
|
||||||
|
let is_horizontal = |(p, q): (Vec2, Vec2)| (q.y - p.y).abs() <= (q.x - p.x).abs() * max_slope;
|
||||||
|
let is_vertical = |(p, q): (Vec2, Vec2)| (q.x - p.x).abs() <= (q.y - p.y).abs() * max_slope;
|
||||||
|
let (horizontal, vertical) = if is_horizontal(a) && is_vertical(b) {
|
||||||
|
(a, b)
|
||||||
|
} else if is_vertical(a) && is_horizontal(b) {
|
||||||
|
(b, a)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (x_min, x_max) = (
|
||||||
|
horizontal.0.x.min(horizontal.1.x),
|
||||||
|
horizontal.0.x.max(horizontal.1.x),
|
||||||
|
);
|
||||||
|
let (y_min, y_max) = (
|
||||||
|
vertical.0.y.min(vertical.1.y),
|
||||||
|
vertical.0.y.max(vertical.1.y),
|
||||||
|
);
|
||||||
|
let origin = Vec2::new(
|
||||||
|
(vertical.0.x + vertical.1.x) * 0.5,
|
||||||
|
(horizontal.0.y + horizontal.1.y) * 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vier Arme: jeder mindestens ein Fünftel seiner Achse, die Achsen nicht winzig
|
||||||
|
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||||
|
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2;
|
||||||
|
if width < 30.0
|
||||||
|
|| height < 30.0
|
||||||
|
|| !(arm_ok(origin.x - x_min, width)
|
||||||
|
&& arm_ok(x_max - origin.x, width)
|
||||||
|
&& arm_ok(origin.y - y_min, height)
|
||||||
|
&& arm_ok(y_max - origin.y, height))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
|
||||||
|
Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end: Vec2::new(x_max, origin.y),
|
||||||
|
y_axis_end: Vec2::new(origin.x, y_min),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerade, die ein Strich darstellt: eine als Gerade eingerastete Form oder ein freihändiger
|
||||||
|
/// Strich, den die Formerkennung als Gerade erkennt. Bilder, Textfelder und Textmarker zählen
|
||||||
|
/// nicht.
|
||||||
|
pub fn stroke_as_line(stroke: &Stroke) -> Option<(Vec2, Vec2)> {
|
||||||
|
if stroke.image.is_some()
|
||||||
|
|| stroke.text.is_some()
|
||||||
|
|| stroke.tool == crate::document::StrokeTool::Highlighter
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shape = match &stroke.shape {
|
||||||
|
Some(shape) => shape.clone(),
|
||||||
|
None => detect_shape(&stroke.points)?,
|
||||||
|
};
|
||||||
|
match shape {
|
||||||
|
RecognizedShape::Line { start, end } => Some((start, end)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Führt die automatische Erkennung für einen Strich aus.
|
/// Führt die automatische Erkennung für einen Strich aus.
|
||||||
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||||
if points.len() < 2 {
|
if points.len() < 2 {
|
||||||
@@ -2674,6 +2745,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_axis_cross_from_two_lines() {
|
||||||
|
let horizontal = (Vec2::new(100.0, 305.0), Vec2::new(400.0, 300.0));
|
||||||
|
let vertical = (Vec2::new(252.0, 450.0), Vec2::new(248.0, 150.0));
|
||||||
|
for (a, b) in [(horizontal, vertical), (vertical, horizontal)] {
|
||||||
|
let Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
}) = axis_cross_from_lines(a, b)
|
||||||
|
else {
|
||||||
|
panic!("kein Achsenkreuz");
|
||||||
|
};
|
||||||
|
assert_eq!(origin, Vec2::new(250.0, 302.5));
|
||||||
|
assert_eq!(x_axis_end, Vec2::new(400.0, 302.5));
|
||||||
|
assert_eq!(x_start, Vec2::new(100.0, 302.5));
|
||||||
|
assert_eq!(y_axis_end, Vec2::new(250.0, 150.0));
|
||||||
|
assert_eq!(y_start, Vec2::new(250.0, 450.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_axis_cross_from_lines_that_do_not_cross() {
|
||||||
|
let horizontal = (Vec2::new(100.0, 300.0), Vec2::new(400.0, 300.0));
|
||||||
|
// Senkrechte endet über der Waagerechten (L oder T statt Kreuz)
|
||||||
|
let above = (Vec2::new(250.0, 100.0), Vec2::new(250.0, 290.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, above).is_none());
|
||||||
|
// Senkrechte schneidet ganz am Rand
|
||||||
|
let at_edge = (Vec2::new(110.0, 150.0), Vec2::new(110.0, 450.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, at_edge).is_none());
|
||||||
|
// Zwei Waagerechte oder eine schräge Linie
|
||||||
|
let parallel = (Vec2::new(100.0, 350.0), Vec2::new(400.0, 350.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, parallel).is_none());
|
||||||
|
let diagonal = (Vec2::new(150.0, 150.0), Vec2::new(350.0, 450.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, diagonal).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_axis_cross_in_one_stroke() {
|
fn test_detect_axis_cross_in_one_stroke() {
|
||||||
let c = Vec2::new(800.0, 900.0);
|
let c = Vec2::new(800.0, 900.0);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod geometry;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
|
|||||||
+94
-2
@@ -12,6 +12,7 @@ use winit::{
|
|||||||
event_loop::{ControlFlow, EventLoopBuilder},
|
event_loop::{ControlFlow, EventLoopBuilder},
|
||||||
keyboard::{Key, ModifiersState, NamedKey},
|
keyboard::{Key, ModifiersState, NamedKey},
|
||||||
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
||||||
|
raw_window_handle::{HasDisplayHandle, RawDisplayHandle},
|
||||||
window::{Window, WindowBuilder},
|
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::geometry::{self, RecognizedShape};
|
||||||
use ink_notes::image_layer::TextureSource;
|
use ink_notes::image_layer::TextureSource;
|
||||||
use ink_notes::library::Library;
|
use ink_notes::library::Library;
|
||||||
|
use ink_notes::osk::{self, KeyboardRequester};
|
||||||
use ink_notes::pdf_doc::PdfRenderService;
|
use ink_notes::pdf_doc::PdfRenderService;
|
||||||
use ink_notes::platform::{self, DisplayBackend};
|
use ink_notes::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
@@ -285,6 +287,23 @@ fn start_pdf_export(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz aus der eingerasteten Geraden `shape` und dem vorigen Strich der aktuellen
|
||||||
|
/// Seite, falls sich beide kreuzen (Issue #20); mit der Id des vorigen Strichs
|
||||||
|
fn axis_cross_with_previous(
|
||||||
|
notebook: &Notebook,
|
||||||
|
shape: &RecognizedShape,
|
||||||
|
) -> Option<(u64, RecognizedShape)> {
|
||||||
|
let RecognizedShape::Line { start, end } = shape else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let previous = notebook.current_page().strokes.last()?;
|
||||||
|
let line = geometry::stroke_as_line(previous)?;
|
||||||
|
Some((
|
||||||
|
previous.id,
|
||||||
|
geometry::axis_cross_from_lines(line, (*start, *end))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
|
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
|
||||||
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
|
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
|
||||||
fn commit_stroke_to_notebook(
|
fn commit_stroke_to_notebook(
|
||||||
@@ -296,6 +315,27 @@ fn commit_stroke_to_notebook(
|
|||||||
window: &Window,
|
window: &Window,
|
||||||
snapped_shape: Option<RecognizedShape>,
|
snapped_shape: Option<RecognizedShape>,
|
||||||
) {
|
) {
|
||||||
|
// Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der
|
||||||
|
// vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her
|
||||||
|
if let Some(shape) = &snapped_shape {
|
||||||
|
if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) {
|
||||||
|
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
|
canvas.cancel_stroke();
|
||||||
|
let stroke = geometry::shape_to_stroke(
|
||||||
|
cross,
|
||||||
|
previous_id,
|
||||||
|
canvas.current_ink_color,
|
||||||
|
stroke_width,
|
||||||
|
);
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.replace_stroke(previous_id, stroke);
|
||||||
|
canvas.sync_page(page);
|
||||||
|
autosave.mark_dirty(notebook, manifest);
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let new_id = notebook.next_stroke_id();
|
let new_id = notebook.next_stroke_id();
|
||||||
let stroke_opt = if let Some(shape) = snapped_shape {
|
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 stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
@@ -1148,6 +1188,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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)
|
// 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 _ = storage::cleanup_trash(
|
let _ = storage::cleanup_trash(
|
||||||
@@ -1307,6 +1358,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut pinch_prev_distance: Option<f32> = None;
|
let mut pinch_prev_distance: Option<f32> = None;
|
||||||
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
||||||
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
|
// Alle aufliegenden Finger (auch auf der UI), unabhängig von der Gestenauswertung
|
||||||
|
let mut touches_down: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
@@ -1809,6 +1862,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
|
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
|
||||||
stroke_already_snapped = true;
|
stroke_already_snapped = true;
|
||||||
stroke_snapped_shape = Some(shape.clone());
|
stroke_snapped_shape = Some(shape.clone());
|
||||||
|
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
|
||||||
|
// Achsenkreuz (übernommen wird es beim Absetzen)
|
||||||
|
let shape = axis_cross_with_previous(¬ebook, &shape)
|
||||||
|
.map_or(shape, |(_, cross)| cross);
|
||||||
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
|
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
|
||||||
canvas.set_live_shape_mesh(samples, vertices);
|
canvas.set_live_shape_mesh(samples, vertices);
|
||||||
@@ -1869,7 +1926,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
Event::WindowEvent { event, window_id } if window_id == window.id() => {
|
Event::WindowEvent { event, window_id } if window_id == window.id() => {
|
||||||
// 1. Window-Event zuerst an egui übergeben
|
// 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 {
|
if egui_response.repaint {
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2938,7 +3018,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. egui Eingabe erfassen und Frame starten
|
// 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);
|
egui_ctx.begin_frame(raw_input);
|
||||||
|
|
||||||
// 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso)
|
// 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso)
|
||||||
@@ -3702,7 +3786,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// 4. egui Frame beenden & Primitives tessellieren
|
// 4. egui Frame beenden & Primitives tessellieren
|
||||||
let full_output = egui_ctx.end_frame();
|
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);
|
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
|
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
|
||||||
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
||||||
|
|||||||
+294
@@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user