Compare commits

...
Author SHA1 Message Date
haraldandClaude Opus 5.5 c92bf16cbf feat: Achsenkreuz aus zwei Strichen (Issue #20)
Kreuzt eine eingerastete Gerade (Halten oder Formen-Werkzeug) den
vorigen Strich, und ist dieser ebenfalls eine Gerade (eingerastet oder
freihändig gerade gezogen), werden beide zum Achsenkreuz mit Pfeilspitzen
an allen vier Enden. Eine Gerade muss waagerecht, die andere senkrecht
sein (je höchstens 25° schief), und jeder Arm reicht mindestens ein
Fünftel seiner Achse über den Schnittpunkt hinaus. Beim Halten zeigt die
Vorschau schon das Achsenkreuz; Rückgängig stellt die erste Gerade wieder
her. Ein T oder L bleibt zwei Geraden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:45:58 +02:00
haraldandClaude Opus 5.5 57028dfbc6 chore(release): bump version to 0.23.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:21:29 +02:00
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
haraldandClaude Opus 5.5 1148851585 chore(release): bump version to 0.22.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:51:17 +02:00
haraldandClaude Opus 5.5 eef4cc6b2b feat: App-Symbol für das laufende Fenster
- Fenster meldet die App-Kennung eu.pansi.ink-notes (Wayland: app_id, X11:
  WM_CLASS), passend zur .desktop-Datei (StartupWMClass): Dock und Taskleiste
  zeigen das ink-notes-Symbol statt eines allgemeinen Symbols
- Unter X11 zusätzlich das eingebaute Symbol als Fenstersymbol (64 × 64)
- Fenstertitel ohne Werbezusatz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:13 +02:00
haraldandClaude Opus 5.5 dc067af31e chore(release): bump version to 0.22.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:31:28 +02:00
haraldandClaude Opus 5.5 693f62c2e7 feat: Werkzeug „Platz schaffen“ (Issue #22)
- Neues Werkzeug ↕: aufs Blatt tippen und ziehen; alles unterhalb der
  Ansatzhöhe rutscht mit, auch der Inhalt der folgenden Seiten (bis vor die
  nächste PDF-Seite)
- Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite
  (10 mm Abstand), alles danach rutscht weiter; fehlende Seiten werden angehängt
- Nach oben wird nur leerer Platz geschlossen
- Vorschau beim Ziehen (Band mit Millimeterangabe); mit Maus, Stift und Finger
- Ein Schritt für Rückgängig/Wiederholen über alle beteiligten Seiten
- Nachbarseiten werden neu gezeichnet, wenn sich ihr Inhalt bei gleicher
  Strichzahl ändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:15:18 +02:00
haraldandClaude Opus 5.5 f212b2e23f chore(release): bump version to 0.21.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:42:48 +02:00
15 changed files with 1196 additions and 32 deletions
Generated
+4 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.20.1"
version = "0.23.0"
dependencies = [
"ab_glyph",
"arboard",
@@ -1418,6 +1418,9 @@ dependencies = [
"tempfile",
"ureq",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"webbrowser",
"wgpu",
"winit",
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.20.1"
version = "0.23.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -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
+1
View File
@@ -25,6 +25,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
### Formerkennung
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
+2
View File
@@ -132,6 +132,8 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`):
### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
---
## 6. Formerkennung
+1 -1
View File
@@ -9,7 +9,7 @@ Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+1 -1
View File
@@ -9,7 +9,7 @@ Terminal=false
Type=Application
Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes
StartupWMClass=eu.pansi.ink-notes
MimeType=application/x-inknotes;
Actions=NewWindow;
+4
View File
@@ -3,6 +3,10 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **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)
- **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
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
+3
View File
@@ -128,6 +128,8 @@ struct NeighborFingerprint {
vertex_count: usize,
stroke_count: usize,
last_stroke_id: Option<u64>,
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
cache_generation: u64,
}
impl NeighborFingerprint {
@@ -141,6 +143,7 @@ impl NeighborFingerprint {
vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id),
cache_generation: page.cache_generation,
}
}
}
+402
View File
@@ -722,6 +722,8 @@ pub struct Page {
pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
pub cache_generation: u64,
pub is_loaded: bool,
pub persist: PersistState,
pub history: HistoryStack,
@@ -742,6 +744,7 @@ impl Page {
is_loaded: true,
persist: PersistState::Normal,
history: HistoryStack::new(),
cache_generation: 0,
}
}
@@ -760,6 +763,7 @@ impl Page {
is_loaded: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
cache_generation: 0,
}
}
@@ -958,6 +962,7 @@ impl Page {
}
self.is_dirty = false;
self.cache_generation += 1;
}
/// Berechnet die kombinierte AABB einer Menge von Strichen.
@@ -1315,6 +1320,298 @@ impl Default for Notebook {
}
}
/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim
/// Platzschaffen über das Seitenende rutschen würde (10 mm).
pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM;
/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite.
#[derive(Debug, Clone)]
pub struct StrokeMove {
pub from_page: u64,
pub before: Stroke,
pub to_page: u64,
pub after: Stroke,
}
/// Für das Platzschaffen neu eingefügte Seite.
#[derive(Debug, Clone)]
pub struct AddedPage {
pub id: u64,
/// Seite, hinter der sie liegt
pub after_page: u64,
pub size: glam::Vec2,
pub background: PageBackground,
}
/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen).
#[derive(Debug, Clone)]
pub struct SpaceShift {
pub moves: Vec<StrokeMove>,
pub added_pages: Vec<AddedPage>,
}
impl Notebook {
/// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die
/// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur
/// PDF-Seite). `None` auf einer PDF-Seite.
pub fn space_run(&self, page_index: usize) -> Option<std::ops::Range<usize>> {
let page = self.pages.get(page_index)?;
if page.pdf_background.is_some() {
return None;
}
let end = (page_index + 1..self.pages.len())
.find(|&i| self.pages[i].pdf_background.is_some())
.unwrap_or(self.pages.len());
Some(page_index..end)
}
/// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben
/// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand).
pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 {
let Some(page) = self.pages.get(page_index) else {
return 0.0;
};
let bottom = page
.strokes
.iter()
.filter(|s| s.aabb.min.y < y)
.map(|s| s.aabb.max.y)
.fold(0.0_f32, f32::max);
(y - bottom).max(0.0)
}
/// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der
/// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten
/// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei
/// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der
/// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht
/// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
let run = self.space_run(page_index)?;
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
return None;
}
let dy = if dy < 0.0 {
dy.max(-self.space_up_limit(page_index, y))
} else {
dy
};
if dy.abs() < 1.0 {
return None;
}
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size;
let mut tops = Vec::new();
let mut heights = Vec::new();
let mut top = 0.0;
for page in &self.pages[run.clone()] {
tops.push(top);
heights.push(page.size.y);
top += page.size.y;
}
// Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten
let mut movable: Vec<(usize, f32, Stroke)> = Vec::new();
for (k, page) in self.pages[run.clone()].iter().enumerate() {
for stroke in &page.strokes {
if k > 0 || stroke.aabb.min.y >= y {
movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone()));
}
}
}
if movable.is_empty() {
return None;
}
movable.sort_by(|a, b| a.1.total_cmp(&b.1));
let mut extra = 0.0_f32;
let mut targets = Vec::with_capacity(movable.len());
for (_, global_top, stroke) in &movable {
let height = stroke.aabb.max.y - stroke.aabb.min.y;
let mut new_top = global_top + dy + extra;
let mut target = 0;
for _ in 0..2 {
// Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten)
target = 0;
while target + 1 < tops.len() && tops[target + 1] <= new_top {
target += 1;
}
while tops[target] + heights[target] <= new_top {
tops.push(tops[target] + heights[target]);
heights.push(last_size.y);
target += 1;
}
let page_end = tops[target] + heights[target];
let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN;
if new_top + height <= page_end || !fits_on_a_page {
break;
}
// Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit
let margin = if dy > 0.0 {
SPACE_PAGE_BREAK_MARGIN
} else {
0.0
};
let bump = page_end + margin - new_top;
extra += bump;
new_top += bump;
}
targets.push((target, new_top));
}
// Fehlende Seiten hinter dem Abschnitt einfügen
let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0);
let mut added_pages = Vec::new();
let background = self.pages[run.end - 1].background;
for k in run.len()..needed {
let after_page = self.pages[run.start + k - 1].id;
let id = self.next_page_id;
self.next_page_id += 1;
self.pages.insert(
run.start + k,
Page::new(id, background).with_size(last_size),
);
added_pages.push(AddedPage {
id,
after_page,
size: last_size,
background,
});
}
// Umbuchen
let mut moves = Vec::with_capacity(movable.len());
for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) {
let mut after = before.clone();
after.translate(glam::Vec2::new(
0.0,
new_top - tops[target] - before.aabb.min.y,
));
moves.push(StrokeMove {
from_page: self.pages[run.start + k].id,
before,
to_page: self.pages[run.start + target].id,
after,
});
}
let shift = SpaceShift { moves, added_pages };
let changed = self.apply_space_moves(&shift, false);
self.pages[page_index]
.history
.push(HistoryAction::SpaceShift(Box::new(shift)));
Some(changed)
}
/// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die
/// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt.
fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec<u64> {
let mut changed = Vec::new();
let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id);
for m in &shift.moves {
let (from, old, to, new) = if undo {
(m.to_page, &m.after, m.from_page, &m.before)
} else {
(m.from_page, &m.before, m.to_page, &m.after)
};
let (Some(from_i), Some(to_i)) =
(index_of(&self.pages, from), index_of(&self.pages, to))
else {
continue;
};
let Some(pos) = self.pages[from_i]
.strokes
.iter()
.position(|s| s.id == old.id)
else {
continue;
};
self.pages[from_i].strokes.remove(pos);
self.pages[to_i].strokes.push(new.clone());
for i in [from_i, to_i] {
self.pages[i].is_dirty = true;
if !changed.contains(&self.pages[i].id) {
changed.push(self.pages[i].id);
}
}
}
changed
}
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
pub fn undo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
else {
unreachable!()
};
let mut changed = self.apply_space_moves(&shift, true);
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
for added in shift.added_pages.iter().rev() {
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
if self.pages[i].strokes.is_empty() && self.pages.len() > 1 {
self.pages.remove(i);
if self.current_page_index >= i && self.current_page_index > 0 {
self.current_page_index -= 1;
}
changed.retain(|&id| id != added.id);
}
}
}
// Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt)
let owner = self.current_page_index;
self.pages[owner]
.history
.redo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.undo().then(|| vec![page.id])
}
/// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her.
pub fn redo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
else {
unreachable!()
};
// Entfernte Seiten wieder einfügen
for added in &shift.added_pages {
if self.pages.iter().any(|p| p.id == added.id) {
continue;
}
let at = self
.pages
.iter()
.position(|p| p.id == added.after_page)
.map_or(self.pages.len(), |i| i + 1);
self.pages.insert(
at,
Page::new(added.id, added.background).with_size(added.size),
);
if at <= self.current_page_index {
self.current_page_index += 1;
}
}
let changed = self.apply_space_moves(&shift, false);
let owner = self.current_page_index;
self.pages[owner]
.history
.undo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.redo().then(|| vec![page.id])
}
}
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
@@ -2005,6 +2302,111 @@ mod tests {
assert!(new.pdf_background.is_none());
}
/// Senkrechter Strich bei x = 300 von `y` bis `y + h`.
fn block(id: u64, y: f32, h: f32) -> Stroke {
let samples = [y, y + h]
.map(|y| StylusSample {
position: glam::Vec2::new(300.0, y),
pressure: 0.5,
timestamp: 0.0,
})
.to_vec();
Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0])
}
fn top_of(nb: &Notebook, page: usize, id: u64) -> Option<f32> {
nb.pages[page]
.strokes
.iter()
.find(|s| s.id == id)
.map(|s| s.aabb.min.y)
}
#[test]
fn test_make_space_moves_content_below_and_following_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Lined);
let a4 = A4_SIZE.y;
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
nb.pages[1].strokes = vec![block(3, 500.0, 100.0)];
// 30 mm Platz bei y = 800 auf Seite 1
let changed = nb.make_space(0, 800.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen");
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(
top_of(&nb, 1, 3),
Some(800.0),
"die nächste Seite rutscht mit"
);
assert_eq!(changed.len(), 2);
assert_eq!(nb.pages.len(), 2);
// Rückgängig und wiederholen über beide Seiten
nb.current_page_index = 0;
assert!(nb.undo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1000.0));
assert_eq!(top_of(&nb, 1, 3), Some(500.0));
assert!(nb.redo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(top_of(&nb, 1, 3), Some(800.0));
// Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)];
nb.make_space(0, a4 - 500.0, 300.0).unwrap();
assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt");
assert_eq!(nb.pages[1].background, nb.pages[0].background);
assert!(nb.pages[0].strokes.is_empty());
let first = top_of(&nb, 1, 1).unwrap();
assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}");
// Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten)
assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3);
// Rückgängig entfernt die angehängte leere Seite
assert!(nb.undo().is_some());
assert_eq!(nb.pages.len(), 1);
assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0));
assert!(nb.redo().is_some());
assert_eq!(nb.pages.len(), 2);
}
#[test]
fn test_make_space_upwards_only_closes_empty_space() {
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
// Nach oben höchstens bis an das Objekt darüber (Unterkante 300)
assert_eq!(nb.space_up_limit(0, 900.0), 600.0);
nb.make_space(0, 900.0, -2000.0).unwrap();
assert_eq!(top_of(&nb, 0, 2), Some(400.0));
assert_eq!(top_of(&nb, 0, 1), Some(200.0));
// Kein Platz mehr: nichts zu tun
assert!(nb.make_space(0, 300.5, -50.0).is_none());
}
#[test]
fn test_make_space_stops_before_pdf_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Blank);
nb.add_page(PageBackground::Blank);
nb.pages[1].pdf_background = Some(PdfBackground {
asset: "x.pdf".to_string(),
page: 0,
width_pt: 595.0,
height_pt: 842.0,
});
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
nb.pages[2].strokes = vec![block(2, 500.0, 100.0)];
assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten");
assert_eq!(nb.space_run(0), Some(0..1));
nb.make_space(0, 900.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(1300.0));
assert_eq!(
top_of(&nb, 2, 2),
Some(500.0),
"hinter der PDF-Seite bleibt alles"
);
}
#[test]
fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new();
+110
View File
@@ -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.
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
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]
fn test_detect_axis_cross_in_one_stroke() {
let c = Vec2::new(800.0, 900.0);
+10
View File
@@ -21,6 +21,9 @@ pub enum HistoryAction {
before: Vec<Stroke>,
after: Vec<Stroke>,
},
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
SpaceShift(Box<crate::document::SpaceShift>),
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes {
removed: Vec<Stroke>,
@@ -139,6 +142,10 @@ impl HistoryStack {
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
HistoryAction::SpaceShift(shift) => {
self.redo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
@@ -190,6 +197,9 @@ impl HistoryStack {
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SpaceShift(shift) => {
self.undo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
+1
View File
@@ -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;
+272 -23
View File
@@ -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::{
@@ -37,6 +39,106 @@ use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
/// Kennung der App (Name der .desktop-Datei und des Symbols).
const APP_ID: &str = "eu.pansi.ink-notes";
/// Fenstersymbol aus dem eingebauten App-Symbol, auf 64 × 64 Pixel verkleinert: In voller
/// Größe (256 × 256) überschreitet es die Größe einer einzelnen X11-Anfrage und fehlt dann.
fn app_icon() -> Option<winit::window::Icon> {
const SIZE: u32 = 64;
let image = image::load_from_memory(include_bytes!("../assets/ink-notes.png")).ok()?;
let small = image::imageops::resize(
&image.into_rgba8(),
SIZE,
SIZE,
image::imageops::FilterType::Triangle,
);
winit::window::Icon::from_rgba(small.into_raw(), SIZE, SIZE).ok()
}
/// Platz schaffen: Druck auf das Blatt (physische Pixel) setzt die Ansatzhöhe auf der
/// aktuellen Seite. `false` neben dem Blatt oder auf einer PDF-Seite.
fn space_press(
screen: (f32, f32),
canvas: &CanvasState,
notebook: &Notebook,
ui_state: &mut UiState,
) -> bool {
let Some(page_pt) = canvas.screen_to_page(screen.0, screen.1) else {
return false;
};
let index = notebook.current_page_index;
if notebook.space_run(index).is_none() {
ui_state.set_export_message(
"Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(),
);
return false;
}
ui_state.space_drag = Some(ui::SpaceDrag {
page_index: index,
y: page_pt.y,
dy: 0.0,
up_limit: notebook.space_up_limit(index, page_pt.y),
});
true
}
/// Platz schaffen: Verschiebung aus dem senkrechten Abstand zur Ansatzhöhe.
fn space_move(screen: (f32, f32), canvas: &CanvasState, ui_state: &mut UiState) {
if let Some(drag) = &mut ui_state.space_drag {
let start = canvas.page_to_screen(Vec2::new(0.0, drag.y));
let below = canvas.page_to_screen(Vec2::new(0.0, drag.y + 1000.0));
let px_per_unit = (below[1] - start[1]) / 1000.0;
if px_per_unit > 0.0 {
drag.dy = (screen.1 - start[1]) / px_per_unit;
}
}
}
/// Platz schaffen beim Loslassen ausführen (alle beteiligten Seiten werden dafür geladen).
fn space_release(
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
storage_dir: &Path,
ui_state: &mut UiState,
) {
let Some(drag) = ui_state.space_drag.take() else {
return;
};
if let Some(run) = notebook.space_run(drag.page_index) {
for index in run {
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
}
}
let dy = drag.clamped_dy();
if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) {
persist_pages(&changed, notebook, manifest, autosave);
canvas.sync_page(notebook.current_page_mut());
let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round();
ui_state.set_export_message(if dy > 0.0 {
format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)")
} else {
format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)")
});
}
}
/// Merkt geänderte Seiten (Kennungen) und das Manifest zum Speichern vor.
fn persist_pages(
ids: &[u64],
notebook: &Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
) {
manifest.sync_from_notebook(notebook);
for page in notebook.pages.iter().filter(|p| ids.contains(&p.id)) {
autosave.mark_page_dirty(page, manifest);
}
autosave.mark_manifest_dirty(manifest);
}
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
ui_state.active_tool = tool;
@@ -49,7 +151,7 @@ fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasSt
canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
}
}
@@ -185,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)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook(
@@ -196,6 +315,27 @@ fn commit_stroke_to_notebook(
window: &Window,
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 stroke_opt = if let Some(shape) = snapped_shape {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
@@ -1016,12 +1156,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
DisplayBackend::Automatic => {}
}
let event_loop = event_loop_builder.build()?;
let window = Arc::new(
WindowBuilder::new()
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 850.0))
.build(&event_loop)?,
);
// App-Kennung wie die .desktop-Datei (eu.pansi.ink-notes): Daran ordnen Dock und Taskleiste
// das Fenster dem App-Symbol zu (Wayland: app_id, X11: WM_CLASS). Das Fenstersymbol selbst
// nutzt nur X11.
let builder = WindowBuilder::new()
.with_title("ink-notes")
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 850.0))
.with_window_icon(app_icon());
let builder =
winit::platform::wayland::WindowBuilderExtWayland::with_name(builder, APP_ID, "ink-notes");
let builder =
winit::platform::x11::WindowBuilderExtX11::with_name(builder, APP_ID, "ink-notes");
let window = Arc::new(builder.build(&event_loop)?);
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
Ok(canvas) => canvas,
@@ -1042,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)
let storage_base_dir = storage::default_storage_dir();
let _ = storage::cleanup_trash(
@@ -1201,6 +1358,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut pinch_prev_distance: Option<f32> = None;
// 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();
// 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 is_pinch_gesturing = false;
let mut is_touch_drawing = false;
@@ -1219,6 +1378,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
let mut stylus_held: Option<(u64, StylusAction)> = None;
let mut tool_before_button: Option<ActiveTool> = None;
// Platz schaffen läuft mit dem Stift bzw. dem Finger (sonst mit der Maus)
let mut is_stylus_spacing = false;
let mut is_touch_spacing = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false;
@@ -1390,6 +1552,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing {
is_erasing = false;
}
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if is_stylus_selecting {
is_stylus_selecting = false;
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
@@ -1465,7 +1632,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue;
}
if is_erasing {
if is_stylus_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing {
focus_page_at!((sx, sy));
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt,
@@ -1549,7 +1719,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| button_action == Some(StylusAction::Erase)
|| ui_state.active_tool == ActiveTool::Eraser;
if !should_erase && ui_state.active_tool == ActiveTool::Text {
if !should_erase && ui_state.active_tool == ActiveTool::Space {
is_stylus_spacing = space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
} else if !should_erase && ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -1618,6 +1791,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false;
}
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if is_stylus_selecting {
is_stylus_selecting = false;
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) {
@@ -1683,6 +1862,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
stroke_already_snapped = true;
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(&notebook, &shape)
.map_or(shape, |(_, cross)| cross);
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);
canvas.set_live_shape_mesh(samples, vertices);
@@ -1743,7 +1926,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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();
}
@@ -1905,10 +2111,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Undo: Strg + Z
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() {
if let Some(changed) = notebook.undo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1920,10 +2126,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
{
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() {
if let Some(changed) = notebook.redo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -2010,7 +2216,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Radierer umschalten: E
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
ui_state.active_tool = match ui_state.active_tool {
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser,
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => ActiveTool::Eraser,
ActiveTool::Eraser => ActiveTool::Pen,
};
ui_state.is_pen_menu_open = false;
@@ -2287,6 +2493,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if button == MouseButton::Left {
let (sx, sy) = cursor_screen_pos;
// Platz schaffen: Ansatzhöhe merken
if ui_state.active_tool == ActiveTool::Space {
space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
return;
}
// Text-Werkzeug: Textfeld öffnen
if ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -2348,6 +2561,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing {
is_erasing = false;
}
if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if ui_state.active_tool == ActiveTool::Select
&& !is_stylus_selecting
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
@@ -2401,7 +2618,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten
if !is_stylus_drawing && !is_stylus_selecting {
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
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(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
window.request_redraw();
@@ -2554,6 +2774,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
is_touch_panning = false;
if is_touch_spacing {
is_touch_spacing = false;
ui_state.space_drag = None;
}
is_pinch_gesturing = true;
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
@@ -2594,6 +2818,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
window.request_redraw();
}
} else if ui_state.active_tool == ActiveTool::Space {
is_touch_spacing = space_press((tx, ty), &canvas, &notebook, &mut ui_state);
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;
@@ -2660,6 +2887,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw();
}
} else if is_touch_spacing {
space_move((tx, ty), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing {
focus_page_at!((tx, ty));
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -2692,6 +2922,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false;
}
if is_touch_spacing {
is_touch_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
if ui_state.active_tool == ActiveTool::Select
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
{
@@ -2754,6 +2990,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
let busy = is_drawing
|| is_erasing
|| ui_state.space_drag.is_some()
|| !selection_state.is_empty()
|| selection_state.drag_mode != DragMode::None
|| ui_state.ruler_drag.is_some()
@@ -2781,7 +3018,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// 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)
@@ -2831,7 +3072,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_color = color;
canvas.set_stroke_color(color);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
}
window.request_redraw();
}
@@ -2845,7 +3086,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_stroke_width_mm = width;
canvas.set_stroke_width_mm(width);
}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
}
window.request_redraw();
}
@@ -2920,20 +3161,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
UiAction::Undo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() {
if let Some(changed) = notebook.undo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
}
UiAction::Redo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() {
if let Some(changed) = notebook.redo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest);
persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -3545,7 +3786,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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)
+294
View File
@@ -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));
}
}
+85 -5
View File
@@ -35,6 +35,8 @@ pub enum ActiveTool {
Select,
/// Textfelder schreiben und bearbeiten
Text,
/// Platz schaffen: Inhalt ab einer Höhe nach unten oder oben schieben
Space,
}
impl ActiveTool {
@@ -46,6 +48,7 @@ impl ActiveTool {
ActiveTool::Shape => "Formen (Shape)",
ActiveTool::Select => "Auswahl / Lasso (Select)",
ActiveTool::Text => "Text (T)",
ActiveTool::Space => "Platz schaffen",
}
}
@@ -179,6 +182,23 @@ pub enum MoveSubject {
Folder(String),
}
/// Laufendes Platzschaffen (für die Vorschau): Seite, Höhe auf der Seite, bisherige
/// Verschiebung und wie weit es nach oben höchstens geht (Seiteneinheiten).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceDrag {
pub page_index: usize,
pub y: f32,
pub dy: f32,
pub up_limit: f32,
}
impl SpaceDrag {
/// Verschiebung nach oben begrenzt auf den leeren Platz.
pub fn clamped_dy(&self) -> f32 {
self.dy.max(-self.up_limit)
}
}
/// Kontextmenü nach langem Drücken auf das Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenu {
@@ -378,6 +398,8 @@ pub struct UiState {
pub show_color_picker: bool,
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
pub context_menu: Option<ContextMenu>,
/// Laufendes Platzschaffen (Vorschau)
pub space_drag: Option<SpaceDrag>,
/// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben
pub stylus_buttons: Vec<crate::settings::StylusButton>,
pub pressed_stylus_button: Option<usize>,
@@ -476,6 +498,7 @@ impl Default for UiState {
is_text_color_menu_open: false,
show_color_picker: false,
context_menu: None,
space_drag: None,
stylus_buttons: Vec::new(),
pressed_stylus_button: None,
confirm_delete_page: false,
@@ -593,10 +616,10 @@ impl UiState {
/// Blätterpfeile).
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 {
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
// Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern:
// dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
// Notizbücher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln
// blättern: dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
let page_buttons = if continuous { 2 } else { 4 };
let buttons = 10 + page_buttons + if select { 4 } else { 0 };
let buttons = 11 + page_buttons + if select { 4 } else { 0 };
let dividers = 5 + usize::from(select);
let spacing = 3.0;
let needed = |b: f32| {
@@ -679,7 +702,7 @@ impl UiState {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
ActiveTool::Highlighter => self.highlighter_color,
ActiveTool::Text => self.text_color,
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0],
ActiveTool::Eraser | ActiveTool::Space => [0.0, 0.0, 0.0, 0.0],
}
}
@@ -690,7 +713,7 @@ impl UiState {
self.pen_stroke_width_mm
}
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
ActiveTool::Eraser => 3.0,
ActiveTool::Eraser | ActiveTool::Space => 3.0,
}
}
@@ -2125,6 +2148,55 @@ fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAc
}
}
/// Vorschau beim Platzschaffen: Linie an der Ansatzhöhe und das Band, das eingefügt (grün)
/// bzw. geschlossen (orange) wird, mit Angabe in Millimetern.
fn space_preview(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let (Some(drag), Some(view)) = (state.space_drag, state.page_view) else {
return;
};
let Some(page) = notebook.pages.get(drag.page_index) else {
return;
};
let dy = drag.clamped_dy();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("space_preview"),
));
let left = view.to_screen(glam::Vec2::new(0.0, drag.y));
let right = view.to_screen(glam::Vec2::new(page.size.x, drag.y));
let moved = view.to_screen(glam::Vec2::new(page.size.x, drag.y + dy));
let (fill, line) = if dy >= 0.0 {
(
egui::Color32::from_rgba_unmultiplied(46, 204, 113, 50),
egui::Color32::from_rgb(39, 174, 96),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(230, 126, 34, 60),
egui::Color32::from_rgb(211, 84, 0),
)
};
let band = egui::Rect::from_two_pos(left, moved);
painter.rect_filled(band, 0.0, fill);
painter.line_segment([left, right], egui::Stroke::new(2.0_f32, line));
painter.line_segment(
[egui::pos2(left.x, moved.y), moved],
egui::Stroke::new(2.0_f32, line),
);
let mm = dy / crate::document::UNITS_PER_MM;
painter.text(
egui::pos2(right.x - 8.0, moved.y.max(left.y) + 4.0),
egui::Align2::RIGHT_TOP,
if dy >= 0.0 {
format!("⏷ {mm:.0} mm Platz")
} else {
format!("⏶ {:.0} mm", -mm)
},
egui::FontId::proportional(14.0),
line,
);
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
@@ -2555,6 +2627,7 @@ pub fn render_ui(
let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
context_menu(ctx, state, &mut actions);
@@ -2733,6 +2806,13 @@ pub fn render_ui(
SidebarIcon::Lasso,
"Auswählen: verschieben, drehen, skalieren, kopieren (V)",
),
(
ActiveTool::Space,
SidebarIcon::Glyph("↕"),
"Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \
darunter rutscht mit, auch auf die folgenden Seiten; nach oben \
schließt leeren Platz",
),
(
ActiveTool::Text,
SidebarIcon::Glyph("T"),