Compare commits

...
9 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 0ddd0bc50e chore(release): bump version to 0.30.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:51:33 +02:00
haraldandClaude Opus 5.5 476d0e628f fix: Strg+Plus/Minus vergrößert nicht mehr die Oberfläche (Issue #34)
egui zoomte bei Strg+Plus/Minus zusätzlich zum Blatt die ganze Oberfläche. Danach lagen
das bearbeitete Textfeld (und andere Overlays) um den Zoomfaktor versetzt und vergrößert
über dem Blatt; beim Abwählen sprang der Text an seine echte Stelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:49:11 +02:00
haraldandClaude Opus 5.5 a5692e5430 chore(release): bump version to 0.30.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:15:37 +02:00
haraldandClaude Opus 5.5 6a043802d7 fix: Eingerasteter Textmarker behält Breite (Issue #32), runde Spitzen und ruhiger Strich beim Absetzen (Issue #33), zuverlässigeres Nachgleiten (Issue #28)
- Halten am Strichende rastet Formen in der Breite des gewählten Werkzeugs ein; ein
  eingerasteter Textmarker bleibt Textmarker und bildet kein Achsenkreuz
- Ecken (Spitze einer „1“, Hochzahlen) werden rund verbunden statt stumpf abgeschnitten
- Der Live-Strich nutzt dieselbe Tessellierung wie der fertige Strich, damit er beim
  Abheben nicht springt
- Nachgleiten misst die Geschwindigkeit über die letzten 100 ms statt über einzelne
  Ereignisse; INK_NOTES_DEBUG_FLING gibt die Messung im Terminal aus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:13:09 +02:00
haraldandClaude Opus 5.5 76968f9e58 chore(release): bump version to 0.30.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:57 +02:00
haraldandClaude Opus 5.5 b75858b43a feat: Schneller durch lange Notizbücher scrollen (Issue #28), neue Notizbücher durchgehend (Issue #30)
- Nachgleiten: Nach schnellem Wischen mit dem Finger gleitet das Blatt
  weiter und bremst ab; eine neue Berührung, Klick oder Mausrad hält an
- Bildlaufleiste in der durchgehenden Ansicht gegenüber der
  Werkzeugleiste: Griff ziehen oder auf die Leiste tippen springt durch
  das Notizbuch, beim Ziehen steht die Seitenzahl daneben
- Mausrad scrollt doppelt so weit je Raste
- Neue Notizbücher (auch PDF-Importe) sind durchgehend; beim Anlegen
  stehen nur noch Durchgehend und Endlos zur Wahl, feste Seiten gibt es
  weiter im ☰-Menü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:51 +02:00
haraldandClaude Opus 5.5 5fafff5eaf style: cargo fmt
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:10 +02:00
haraldandClaude Opus 5.5 d21fa91315 chore(release): bump version to 0.29.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 b00767b457 perf: Große Auswahlen flüssig verschieben, drehen und skalieren
Seit 0.27 kopierte die Prüfung des Seitenverhältnis-Schlosses bei jedem
Bild alle ausgewählten Striche und suchte jeden davon quadratisch in der
Seite. Zusammen mit dem Kopieren beim Ziehen lastete das bei großen
Auswahlen den Prozessor aus, das Verschieben kam kaum noch hinterher.

- Schloss und Auswahlrahmen ohne Kopien bestimmen
- Striche beim Ziehen an ihrer Stelle aus dem Ausgangszustand umformen
  statt die Auswahl zweimal zu kopieren; Suche über eine ID-Tabelle
- Stiftbewegungen der Auswahl nur einmal je Bild anwenden
- Endlose Fläche: Mausrad scrollt (Strg+Mausrad zoomt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
14 changed files with 753 additions and 97 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.29.0"
version = "0.30.2"
dependencies = [
"ab_glyph",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.29.0"
version = "0.30.2"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+1 -1
View File
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
- **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
+20
View File
@@ -84,6 +84,26 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.30.2" date="2026-10-20">
<description>
<p>Ctrl+Plus and Ctrl+Minus zoom only the page again, no longer the whole interface. Text boxes being edited now sit exactly where the text ends up.</p>
</description>
</release>
<release version="0.30.1" date="2026-10-20">
<description>
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
</description>
</release>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
+20
View File
@@ -84,6 +84,26 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.30.2" date="2026-10-20">
<description>
<p>Ctrl+Plus and Ctrl+Minus zoom only the page again, no longer the whole interface. Text boxes being edited now sit exactly where the text ends up.</p>
</description>
</release>
<release version="0.30.1" date="2026-10-20">
<description>
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
</description>
</release>
<release version="0.30.0" date="2026-10-19">
<description>
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
</description>
</release>
<release version="0.29.1" date="2026-10-19">
<description>
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
</description>
</release>
<release version="0.29.0" date="2026-10-18">
<description>
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
+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:
- **Fehlerbehebung Textfelder**: Strg+Plus/Minus vergrößert nur noch das Blatt, nicht mehr zusätzlich die Oberfläche – dadurch sprang Text beim Verlassen eines Textfelds an eine andere Stelle
- **Fehlerbehebungen Schreiben**: Ein eingerasteter Textmarker behält seine Breite; Spitzen (etwa einer „1“ oder von Hochzahlen) werden rund statt abgeschnitten gezeichnet, und der Strich springt beim Absetzen nicht mehr. Wischen mit dem Finger gleitet zuverlässiger nach
- **Schneller scrollen**: Schnelles Wischen mit dem Finger gleitet nach; in der durchgehenden Ansicht springt eine Bildlaufleiste (gegenüber der Werkzeugleiste, mit Seitenzahl beim Ziehen) durch lange Notizbücher und PDFs; das Mausrad scrollt doppelt so weit. Neue Notizbücher sind durchgehend, feste Seiten gibt es weiter im ☰-Menü
- **Fehlerbehebung Auswahl**: Verschieben, Drehen und Skalieren großer Auswahlen ist wieder flüssig und lastet den Prozessor nicht mehr aus (seit 0.27 wurde die Auswahl bei jedem Bild mehrfach kopiert). Auf der endlosen Fläche scrollt das Mausrad (Strg+Mausrad zoomt)
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
- **Seitenanordnung je Notizbuch**: Beim Anlegen wählbar – „Feste Seiten“ (Vorgabe) oder „Durchgehend“ –, später im ☰-Menü umschaltbar. Bestehende Notizbücher behalten die bisherige Einstellung
- **Stifte in ihrer Farbe**: Die Stifte in der Werkzeugleiste erscheinen als Stifte in ihrer eigenen Farbe statt als dünner Strich und sind so auf einen Blick zu unterscheiden
+17 -1
View File
@@ -843,6 +843,18 @@ impl CanvasState {
changed
}
/// Durchgehende Ansicht: Scrollt senkrecht, bis die Fenstermitte auf Höhe `y`
/// (Koordinaten der aktuellen Seite) liegt.
pub fn scroll_center_to(&mut self, y: f32) {
let c = self.camera.screen_to_page_unclamped(
self.viewport.page_x + self.viewport.page_width / 2.0,
self.size.height as f32 / 2.0,
&self.viewport,
);
self.camera.pan_offset[1] += c.y - y;
self.update_uniforms();
}
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
/// Seite mit [`Self::sync_page`] zu setzen.
@@ -1312,9 +1324,13 @@ impl CanvasState {
return;
}
// Gleiche Tessellierung wie beim Absetzen (runde Kappen und Ecken), damit der Strich
// beim Abheben nicht sichtbar springt (Issue #33)
let color = self.current_ink_color;
let points = spline::rdp_filter_samples(&self.live_samples, spline::RDP_TOLERANCE_UNITS);
let corners = spline::detect_corners(&points, spline::CORNER_ANGLE_THRESHOLD_DEG);
let raw_strip =
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
spline::generate_finalized_triangle_strip(&points, &corners, &self.spline_config);
self.live_vertices = raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
+72 -30
View File
@@ -1,5 +1,6 @@
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use crate::canvas::InkVertex;
@@ -613,18 +614,24 @@ impl Stroke {
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
pub fn recompute_aabb(&mut self) {
// Ohne Zwischenliste: läuft beim Ziehen einer Auswahl für jeden Strich und jede Bewegung
let bounds = |mut pts: Box<dyn Iterator<Item = glam::Vec2> + '_>| {
pts.next()
.map_or(Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO), |first| {
pts.fold(Aabb::new(first, first), |mut aabb, p| {
aabb.expand_by_point(p);
aabb
})
})
};
self.aabb = if !self.points.is_empty() {
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect();
Aabb::from_points(&pts)
} else if !self.vertices.is_empty() {
let pts: Vec<glam::Vec2> = self
.vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
.collect();
Aabb::from_points(&pts)
bounds(Box::new(self.points.iter().map(|s| s.position)))
} else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
bounds(Box::new(
self.vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1])),
))
};
}
@@ -1066,6 +1073,7 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
let ids: HashSet<u64> = ids.iter().copied().collect();
self.strokes
.iter()
.filter(|s| ids.contains(&s.id))
@@ -1073,24 +1081,31 @@ impl Page {
.collect()
}
/// Ersetzt die Striche mit den IDs aus `modified` an ihrer Stelle. Über eine Tabelle
/// statt je Strich die ganze Seite zu durchsuchen: Beim Ziehen einer großen Auswahl
/// läuft das bei jeder Stiftbewegung.
fn replace_strokes(&mut self, modified: &[Stroke]) {
let index: HashMap<u64, usize> = self
.strokes
.iter()
.enumerate()
.map(|(i, s)| (s.id, i))
.collect();
for m in modified {
if let Some(&pos) = index.get(&m.id) {
self.strokes[pos] = m.clone();
}
}
}
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
if modified.is_empty() {
return;
}
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self
.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.cloned()
.collect();
for m in modified.iter() {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
let before = self.get_strokes(&ids);
self.replace_strokes(&modified);
self.history.push(HistoryAction::ModifyStrokes {
before,
@@ -1101,9 +1116,33 @@ impl Page {
/// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern.
pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) {
for m in modified {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
self.replace_strokes(modified);
self.is_dirty = true;
}
/// Wie [`Self::update_strokes_ephemeral`] für eine Transformation der Ausgangsstriche
/// `originals`: setzt die Striche auf der Seite auf ihren Ausgangszustand zurück und wendet
/// `transform` an. Verwendet die Puffer der Seitenstriche weiter, statt bei jeder
/// Stiftbewegung die ganze Auswahl zweimal zu kopieren.
pub fn transform_strokes_ephemeral(
&mut self,
originals: &[Stroke],
transform: impl Fn(&mut Stroke),
) {
let index: HashMap<u64, usize> = self
.strokes
.iter()
.enumerate()
.map(|(i, s)| (s.id, i))
.collect();
for original in originals {
if let Some(&pos) = index.get(&original.id) {
let stroke = &mut self.strokes[pos];
// Nur was Verschieben, Drehen und Skalieren verändern
stroke.points.clone_from(&original.points);
stroke.vertices.clone_from(&original.vertices);
stroke.shape.clone_from(&original.shape);
transform(stroke);
}
}
self.is_dirty = true;
@@ -1114,11 +1153,7 @@ impl Page {
if before.is_empty() || after.is_empty() {
return;
}
for m in &after {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.replace_strokes(&after);
self.history
.push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true;
@@ -2673,6 +2708,13 @@ mod tests {
// History should not have been updated yet
assert!(!page.history.can_redo());
// Ziehen über die Ausgangsstriche: jede Bewegung gilt ab dem Ausgangszustand
for delta in [glam::Vec2::new(5.0, 0.0), glam::Vec2::new(20.0, 30.0)] {
page.transform_strokes_ephemeral(std::slice::from_ref(&s), |st| st.translate(delta));
}
assert_eq!(page.strokes[0], moved);
assert!(!page.history.can_redo());
// Transactional commit (mouse release)
let before = vec![s.clone()];
let after = vec![moved.clone()];
+96 -44
View File
@@ -326,9 +326,12 @@ fn commit_stroke_to_notebook(
) {
// 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 {
let stroke_width = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM;
if let Some(shape) = snapped_shape
.as_ref()
.filter(|_| ui_state.active_tool != ActiveTool::Highlighter)
{
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,
@@ -347,7 +350,6 @@ fn commit_stroke_to_notebook(
let new_id = notebook.next_stroke_id();
let stroke_opt = if let Some(shape) = snapped_shape {
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
let color = canvas.current_ink_color;
canvas.cancel_stroke();
Some(geometry::shape_to_stroke(
@@ -691,62 +693,65 @@ fn selection_press(
_ => return false,
};
selection_state.drag_mode = mode;
selection_state.pending_drag = None;
true
}
/// Wendet die zuletzt gemeldete Zugposition der Auswahl an (siehe [`selection_drag`]).
fn flush_selection_drag(
page: &mut ink_notes::document::Page,
canvas: &mut CanvasState,
selection_state: &mut SelectionState,
) {
if let Some(page_pt) = selection_state.pending_drag.take() {
selection_drag(page_pt, page, canvas, selection_state);
}
}
/// Auswahlwerkzeug: Ziehen nach `page_pt` – Rahmen aufziehen oder die Auswahl verschieben,
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt).
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt). Bei Stiftbewegungen über
/// `pending_drag` und [`flush_selection_drag`] nur einmal je Bild, weil das bei großen
/// Auswahlen teuer ist.
fn selection_drag(
page_pt: Vec2,
page: &mut ink_notes::document::Page,
canvas: &mut CanvasState,
selection_state: &mut SelectionState,
) {
let mut updated = match &mut selection_state.drag_mode {
match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt;
return;
}
DragMode::None => return,
DragMode::Move {
initial_strokes, ..
}
| DragMode::Rotate {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => initial_strokes.clone(),
};
match &selection_state.drag_mode {
DragMode::Move { start_page, .. } => {
start_page,
initial_strokes,
} => {
let delta = page_pt - *start_page;
updated.iter_mut().for_each(|s| s.translate(delta));
page.transform_strokes_ephemeral(initial_strokes, |s| s.translate(delta));
}
DragMode::Rotate {
center_page,
start_angle,
..
initial_strokes,
} => {
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle;
updated
.iter_mut()
.for_each(|s| s.rotate(*center_page, angle));
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - *start_angle;
let center = *center_page;
page.transform_strokes_ephemeral(initial_strokes, |s| s.rotate(center, angle));
}
DragMode::Scale {
fixed_corner_page,
start_pos_page,
keep_aspect,
initial_strokes,
..
} => {
let factors = scale_factors(*fixed_corner_page, *start_pos_page, page_pt, *keep_aspect);
updated
.iter_mut()
.for_each(|s| s.scale(*fixed_corner_page, factors));
let fixed = *fixed_corner_page;
let factors = scale_factors(fixed, *start_pos_page, page_pt, *keep_aspect);
page.transform_strokes_ephemeral(initial_strokes, |s| s.scale(fixed, factors));
}
DragMode::BoxSelect { .. } | DragMode::None => return,
}
page.update_strokes_ephemeral(&updated);
canvas.sync_page(page);
}
@@ -759,7 +764,10 @@ fn selection_release(
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
canvas: &mut CanvasState,
) -> bool {
// Die letzte Bewegung gehört noch zum Ziehen
flush_selection_drag(notebook.current_page_mut(), canvas, selection_state);
match std::mem::take(&mut selection_state.drag_mode) {
DragMode::BoxSelect {
start_page,
@@ -1315,7 +1323,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. egui Context und State initialisieren
let egui_ctx = egui::Context::default();
egui_ctx.set_visuals(egui::Visuals::dark());
ui::configure_context(&egui_ctx);
// 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen
// Ergebnisse der PDF-Exporte aus dem Hintergrundthread
@@ -1416,6 +1424,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Langes Drücken mit einem Finger öffnet das Kontextmenü
let mut long_press = ui::LongPress::default();
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
// Nachgleiten nach schnellem Wischen mit dem Finger (Issue #28)
let mut fling = ink_notes::viewport::Fling::default();
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
let mut is_stylus_drawing = false;
@@ -1618,7 +1628,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if is_stylus_selecting {
is_stylus_selecting = false;
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas);
window.request_redraw();
}
if let Some(tool) = tool_before_button.take() {
@@ -1714,7 +1724,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if is_stylus_selecting {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
selection_state.pending_drag = Some(page_pt);
window.request_redraw();
}
} else if is_stylus_drawing && is_drawing {
@@ -1858,7 +1868,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_stylus_selecting {
is_stylus_selecting = false;
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) {
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas) {
window.request_redraw();
}
}
@@ -1923,9 +1933,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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;
// Textmarker bilden kein Achsenkreuz
let shape = if ui_state.active_tool == ActiveTool::Highlighter {
shape
} else {
axis_cross_with_previous(&notebook, &shape)
.map_or(shape, |(_, cross)| cross)
};
let width_units = ui_state.current_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);
ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name()));
@@ -1955,6 +1970,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
// Nachgleiten nach dem Wischen
if let Some(delta) = fling.step(Instant::now()) {
canvas.pan(delta.x, delta.y);
window.request_redraw();
}
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at {
if Instant::now() >= at {
@@ -1970,6 +1991,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (is_drawing && !stroke_already_snapped)
|| is_stylus_drawing
|| is_stylus_in_proximity
|| fling.is_active()
{
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
@@ -2074,6 +2096,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
pinch_prev_center = None;
is_touch_drawing = false;
is_touch_panning = false;
fling.stop();
is_stylus_drawing = false;
is_stylus_selecting = false;
is_stylus_in_proximity = false;
@@ -2530,6 +2553,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
}
fling.stop();
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
is_panning = true;
@@ -2626,7 +2650,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if ui_state.active_tool == ActiveTool::Select
&& !is_stylus_selecting
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
{
window.request_redraw();
}
@@ -2682,7 +2706,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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);
selection_state.pending_drag = Some(page_pt);
window.request_redraw();
}
} else if is_erasing {
@@ -2715,15 +2739,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
WindowEvent::MouseWheel { delta, .. } => {
fling.stop();
if is_pointer_in_ui {
return;
}
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht),
// Strg + Mausrad zoomt
if ui_state.continuous_scroll && !modifiers.control_key() {
// Durchgehende Ansicht und endlose Fläche: Mausrad scrollt (mit Umschalt
// waagerecht), Strg + Mausrad zoomt
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => {
let line = 60.0 * window.scale_factor() as f32;
let line = 120.0 * window.scale_factor() as f32;
(x * line, y * line)
}
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
@@ -2796,6 +2821,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match touch.phase {
TouchPhase::Started => {
// Jede neue Berührung hält ein gleitendes Blatt an
fling.stop();
// Event-Isolation für Touch-Eingaben
let is_touch_in_ui = ui::is_screen_point_in_ui(
&egui_ctx,
@@ -2951,6 +2978,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dy = ty - touch_pan_last_pos.1;
touch_pan_last_pos = (tx, ty);
canvas.pan(dx, dy);
fling.track(Vec2::new(dx, dy), Instant::now());
window.request_redraw();
} else if is_touch_drawing {
current_pressure = pressure;
@@ -2983,7 +3011,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
selection_state.pending_drag = Some(page_pt);
window.request_redraw();
}
}
@@ -2997,6 +3025,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
if is_touch_panning && touch.phase == TouchPhase::Ended {
// Letztes Stück bis zum Abheben noch mitnehmen
let (dx, dy) = (tx - touch_pan_last_pos.0, ty - touch_pan_last_pos.1);
canvas.pan(dx, dy);
fling.track(Vec2::new(dx, dy), Instant::now());
let velocity = fling.release(Instant::now());
if std::env::var_os("INK_NOTES_DEBUG_FLING").is_some() {
eprintln!(
"[Nachgleiten] {:.0} px/s beim Abheben, gleitet: {}",
velocity.length(),
fling.is_active()
);
}
window.request_redraw();
}
is_touch_panning = false;
if is_erasing {
@@ -3010,7 +3053,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if ui_state.active_tool == ActiveTool::Select
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
{
window.request_redraw();
}
@@ -3053,6 +3096,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
WindowEvent::RedrawRequested => {
flush_selection_drag(notebook.current_page_mut(), &mut canvas, &mut selection_state);
// 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
@@ -3060,7 +3104,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.set_fit_box(notebook.fit_box());
if ui_state.continuous_scroll {
let stack = notebook.page_stack();
canvas.clamp_scroll(&stack);
if canvas.clamp_scroll(&stack) {
// Am Anfang oder Ende des Notizbuchs angekommen
fling.stop();
}
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
let scroll = [
canvas.camera.zoom,
@@ -3839,6 +3886,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::ScrollTo(y) => {
fling.stop();
canvas.scroll_center_to(y);
window.request_redraw();
}
UiAction::SetNotebookLayout(layout) => {
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
let was_endless = notebook.is_endless();
+14 -4
View File
@@ -88,6 +88,9 @@ pub enum SelectionHandleHit {
pub struct SelectionState {
pub selected_ids: Vec<u64>,
pub drag_mode: DragMode,
/// Letzte Zugposition, die noch nicht angewendet ist: Der Stift meldet sich öfter, als
/// gezeichnet wird; angewendet wird einmal je Bild (siehe `flush_selection_drag`).
pub pending_drag: Option<Vec2>,
pub clipboard: Vec<Stroke>,
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
@@ -99,6 +102,7 @@ impl SelectionState {
Self {
selected_ids: Vec::new(),
drag_mode: DragMode::None,
pending_drag: None,
clipboard: Vec::new(),
aspect_lock: None,
}
@@ -109,10 +113,11 @@ impl SelectionState {
pub fn keeps_aspect(&self, page: &Page) -> bool {
match &self.aspect_lock {
Some((ids, locked)) if *ids == self.selected_ids => *locked,
// Läuft bei jedem Bild: erst die (wenigen) Bilder suchen, ohne Striche zu kopieren
_ => page
.get_strokes(&self.selected_ids)
.strokes
.iter()
.any(|s| s.is_image()),
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
}
}
@@ -159,8 +164,13 @@ impl SelectionState {
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids);
Page::selection_aabb(&strokes)
// Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
let ids: std::collections::HashSet<u64> = self.selected_ids.iter().copied().collect();
page.strokes
.iter()
.filter(|s| ids.contains(&s.id))
.map(|s| s.aabb)
.reduce(|a, b| a.union(&b))
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
+64 -3
View File
@@ -601,6 +601,7 @@ pub fn generate_finalized_triangle_strip(
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
const CAP_STEPS: usize = 10;
const CORNER_STEPS: usize = 24;
let mut vertices = Vec::new();
// 3. Runde Start-Kappe vor dem ersten Run
@@ -622,12 +623,22 @@ pub fn generate_finalized_triangle_strip(
// 4. Runs und Ecken-Übergänge verbinden
for (run_idx, run) in runs.iter().enumerate() {
if run_idx > 0 {
// Ecken-Verbindung: Nahtlose C0-Verbindung am Knick
// Runde Ecken-Verbindung: Ein voller Kreis um den Knick füllt die Außenseite, sonst
// enden beide Abschnitte stumpf und die Spitze (etwa einer „1“) wirkt abgeschnitten.
// Alle Dreiecke liegen im Kreis, auch die Übergänge zum vorigen und nächsten Run.
let s_corner = &run[0];
let p_c = s_corner.position;
let pr_c = s_corner.pressure;
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
let r = s_corner.radius;
let prev_right = runs[run_idx - 1].last().map_or(s_corner.right, |s| s.right);
let d = prev_right - p_c;
let a0 = d.y.atan2(d.x);
for k in 0..=CORNER_STEPS {
let a = a0 + (k as f32 / CORNER_STEPS as f32) * std::f32::consts::TAU;
let rim = p_c + glam::Vec2::new(a.cos(), a.sin()) * r;
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
vertices.push(Vertex::new(rim.x, rim.y, pr_c, 1.0));
}
vertices.push(Vertex::new(s_corner.left.x, s_corner.left.y, pr_c, -1.0));
vertices.push(Vertex::new(s_corner.right.x, s_corner.right.y, pr_c, 1.0));
}
@@ -900,6 +911,56 @@ mod tests {
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
}
/// Liegt `p` in einem der Dreiecke des Triangle-Strips?
fn strip_covers(vertices: &[InkVertex], p: glam::Vec2) -> bool {
let pos = |v: &InkVertex| glam::Vec2::new(v.position[0], v.position[1]);
let cross = |a: glam::Vec2, b: glam::Vec2, c: glam::Vec2| (b - a).perp_dot(c - a);
vertices.windows(3).any(|t| {
let (a, b, c) = (pos(&t[0]), pos(&t[1]), pos(&t[2]));
let (d1, d2, d3) = (cross(a, b, p), cross(b, c, p), cross(c, a, p));
let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(neg && pos)
})
}
#[test]
fn test_sharp_corner_tip_is_not_cut_off() {
// „1“: schräg hinauf bis (100, 0), dann senkrecht hinunter (Issue #33)
let mut raw = Vec::new();
for i in 0..=20 {
let u = i as f32 / 20.0;
raw.push(StylusSample {
position: glam::Vec2::new(60.0 + 40.0 * u, 80.0 - 80.0 * u),
pressure: 0.5,
timestamp: i as f64 * 0.01,
});
}
for i in 1..=30 {
raw.push(StylusSample {
position: glam::Vec2::new(100.0, i as f32 * 5.0),
pressure: 0.5,
timestamp: (20 + i) as f64 * 0.01,
});
}
let config = SplineConfig::from_width_units(10.0);
let stroke = finalize_stroke_with_config(1, &raw, [0.0; 4], &config);
assert_eq!(
detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).len(),
1
);
// Die Spitze reicht rund um den Knick so weit wie die Strichbreite
let r = calculate_radius(0.5, config.min_radius, config.max_radius) * 0.95;
for deg in [-150.0_f32, -120.0, -90.0, -60.0, -30.0, 0.0] {
let a = deg.to_radians();
let p = glam::Vec2::new(100.0, 0.0) + glam::Vec2::new(a.cos(), a.sin()) * r;
assert!(
strip_covers(&stroke.vertices, p),
"Spitze bei {deg}° abgeschnitten"
);
}
}
#[test]
fn test_small_loops_stay_round() {
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
+6 -6
View File
@@ -330,7 +330,7 @@ impl NotebookManifest {
page_ids: vec![1],
next_stroke_id: 1,
next_page_id: 2,
layout: Some(NotebookLayout::Paged),
layout: Some(NotebookLayout::Continuous),
}
}
@@ -1640,14 +1640,14 @@ mod tests {
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
let temp_dir = tempfile::tempdir().unwrap();
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
// Neue Notizbücher: feste Seiten, unabhängig von der früheren globalen Einstellung
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
manifest.layout = Some(NotebookLayout::Continuous);
manifest.layout = Some(NotebookLayout::Paged);
save_manifest(&dir, &manifest).unwrap();
let (_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
assert_eq!(loaded.layout, Some(NotebookLayout::Paged));
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
let mut json: serde_json::Value =
+283 -6
View File
@@ -60,8 +60,9 @@ impl ActiveTool {
/// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält.
pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool {
match self {
_ if has_shape => StrokeTool::Shape,
// Ein eingerasteter Textmarker bleibt Textmarker (durchscheinend, eigener Radierer)
ActiveTool::Highlighter => StrokeTool::Highlighter,
_ if has_shape => StrokeTool::Shape,
_ => StrokeTool::Pen,
}
}
@@ -320,6 +321,9 @@ pub enum UiAction {
SavePressureCurve(PressureCurve),
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
SetNotebookLayout(NotebookLayout),
/// Durchgehende Ansicht: Fenstermitte auf diese Höhe (Koordinaten der aktuellen Seite)
/// scrollen – Bildlaufleiste, Issue #28
ScrollTo(f32),
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
SetPatternColor(PatternColor),
/// Bilddatei auf der aktuellen Seite einfügen
@@ -455,6 +459,8 @@ pub struct UiState {
/// Eingeblendetes Lineal
pub ruler: Option<Ruler>,
pub ruler_drag: Option<RulerDrag>,
/// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs
pub scrollbar_grab: Option<f32>,
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
pub page_view: Option<PageView>,
/// Fenstergröße in logischen Punkten (vom letzten Bild)
@@ -491,7 +497,7 @@ impl Default for UiState {
document_title: t!("Standard-Notizbuch").to_string(),
available_notebooks: Vec::new(),
new_notebook_title_input: String::new(),
new_notebook_layout: NotebookLayout::Paged,
new_notebook_layout: NotebookLayout::Continuous,
renaming_notebook_id: None,
library: crate::library::Library::default(),
notebook_folder: String::new(),
@@ -543,6 +549,7 @@ impl Default for UiState {
queued_actions: Vec::new(),
ruler: None,
ruler_drag: None,
scrollbar_grab: None,
page_view: None,
screen_size: egui::Vec2::ZERO,
page_title_input: String::new(),
@@ -1653,8 +1660,14 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
}
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde.
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen,
/// Issue #30 – umstellen geht im ☰-Menü). `true`, wenn sie geändert wurde.
fn layout_picker(
ui: &mut egui::Ui,
layout: &mut NotebookLayout,
can_cross: bool,
offer_paged: bool,
) -> bool {
let mut changed = false;
let endless_now = *layout == NotebookLayout::Endless;
ui.horizontal_wrapped(|ui| {
@@ -1681,6 +1694,9 @@ fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool
),
),
] {
if option == NotebookLayout::Paged && !offer_paged {
continue;
}
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
if ui
.add_enabled(
@@ -2344,6 +2360,147 @@ fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
}
}
/// Lage der Bildlaufleiste: Leiste (Trefferfläche) und Griff in logischen Punkten. Der Griff
/// zeigt, wo die Fenstermitte `center` zwischen Ober- und Unterkante des Notizbuchs liegt;
/// `visible` ist die sichtbare Höhe (Seiteneinheiten) und bestimmt seine Länge.
fn scrollbar_layout(
screen: egui::Rect,
side: SidebarSide,
(top, bottom): (f32, f32),
center: f32,
visible: f32,
) -> (egui::Rect, egui::Rect) {
const WIDTH: f32 = 28.0;
const MARGIN: f32 = 12.0;
// Gegenüber der Werkzeugleiste
let x = match side {
SidebarSide::Left => screen.right() - WIDTH,
SidebarSide::Right => screen.left(),
};
let track = egui::Rect::from_min_size(
egui::pos2(x, screen.top() + MARGIN),
egui::vec2(WIDTH, (screen.height() - 2.0 * MARGIN).max(1.0)),
);
let total = (bottom - top).max(1.0);
let thumb_h = (visible / (total + visible) * track.height())
.clamp(48.0_f32.min(track.height()), track.height());
let fraction = ((center - top) / total).clamp(0.0, 1.0);
let thumb = egui::Rect::from_min_size(
egui::pos2(
track.left(),
track.top() + fraction * (track.height() - thumb_h),
),
egui::vec2(WIDTH, thumb_h),
);
(track, thumb)
}
/// Durchgehende Ansicht: Bildlaufleiste am Fensterrand gegenüber der Werkzeugleiste. Den
/// Griff ziehen (oder auf die Leiste tippen) springt schnell durch lange Notizbücher und
/// PDFs (Issue #28); beim Ziehen steht die Seitenzahl daneben.
fn page_scrollbar(
ctx: &egui::Context,
state: &mut UiState,
notebook: &Notebook,
actions: &mut Vec<UiAction>,
) {
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
state.scrollbar_grab = None;
return;
};
if notebook.pages.len() < 2 {
state.scrollbar_grab = None;
return;
}
let stack = notebook.page_stack();
let (min, max) = stack.bounds();
let screen = ctx.screen_rect();
let center = view.to_page(screen.center()).y;
let visible = screen.height() / view.scale.max(1e-6);
let (track, thumb) =
scrollbar_layout(screen, state.sidebar_side, (min.y, max.y), center, visible);
let dark = state.paper_theme.is_dark();
let response = egui::Area::new(egui::Id::new("page_scrollbar"))
.order(egui::Order::Middle)
.fixed_pos(track.min)
.show(ctx, |ui| {
let (_, response) = ui.allocate_exact_size(track.size(), egui::Sense::drag());
let active = response.hovered() || response.dragged();
let bar_w = if active { 10.0 } else { 6.0 };
let bar = egui::Rect::from_center_size(
egui::pos2(thumb.center().x, thumb.center().y),
egui::vec2(bar_w, thumb.height()),
);
let alpha = if active { 200 } else { 110 };
let color = if dark {
egui::Color32::from_white_alpha(alpha)
} else {
egui::Color32::from_black_alpha(alpha)
};
ui.painter()
.rect_filled(bar, egui::Rounding::same(bar_w / 2.0), color);
response
})
.inner;
if response.drag_started() {
let y = response
.interact_pointer_pos()
.map_or(thumb.center().y, |p| p.y);
// Auf den Griff: dort festhalten; daneben: Griff unter den Finger springen lassen
state.scrollbar_grab = Some(if thumb.y_range().contains(y) {
y - thumb.top()
} else {
thumb.height() / 2.0
});
}
if !response.dragged() {
state.scrollbar_grab = None;
return;
}
let (Some(grab), Some(pos)) = (state.scrollbar_grab, response.interact_pointer_pos()) else {
return;
};
let free = (track.height() - thumb.height()).max(1.0);
let fraction = ((pos.y - grab - track.top()) / free).clamp(0.0, 1.0);
let target = min.y + fraction * (max.y - min.y);
if (target - center).abs() > 0.5 {
actions.push(UiAction::ScrollTo(target));
}
if let Some(page) = stack.page_at(target) {
let label_x = match state.sidebar_side {
SidebarSide::Left => track.left() - 8.0,
SidebarSide::Right => track.right() + 8.0,
};
let anchor = match state.sidebar_side {
SidebarSide::Left => egui::Align2::RIGHT_CENTER,
SidebarSide::Right => egui::Align2::LEFT_CENTER,
};
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("page_scrollbar_label"),
));
let text = format!("{} / {}", page + 1, notebook.pages.len());
let galley =
painter.layout_no_wrap(text, egui::FontId::proportional(18.0), egui::Color32::WHITE);
let rect = anchor
.anchor_size(egui::pos2(label_x, pos.y), galley.size())
.expand2(egui::vec2(10.0, 6.0));
painter.rect_filled(
rect,
egui::Rounding::same(8.0),
egui::Color32::from_black_alpha(190),
);
painter.galley(
rect.min + egui::vec2(10.0, 6.0),
galley,
egui::Color32::WHITE,
);
}
ctx.request_repaint();
}
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
@@ -2738,6 +2895,14 @@ pub fn is_screen_point_in_ui(
.is_some_and(|layer| layer.order != egui::Order::Background)
}
/// Richtet den egui-Kontext ein. Strg+Plus/Minus/0 zoomen das Blatt; egui darf damit nicht
/// zusätzlich die Oberfläche vergrößern, sonst passen Textfeld, Auswahl und Lineal nicht mehr
/// zum Blatt (Issue #34).
pub fn configure_context(ctx: &egui::Context) {
ctx.set_visuals(egui::Visuals::dark());
ctx.options_mut(|o| o.zoom_with_keyboard = false);
}
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
///
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
@@ -2751,6 +2916,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);
page_scrollbar(ctx, state, notebook, &mut actions);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
@@ -3823,7 +3989,7 @@ pub fn render_ui(
} else {
NotebookLayout::Paged
};
if layout_picker(ui, &mut layout, state.endless_switch_possible) {
if layout_picker(ui, &mut layout, state.endless_switch_possible, true) {
actions.push(UiAction::SetNotebookLayout(layout));
}
@@ -4173,7 +4339,7 @@ pub fn render_ui(
});
ui.horizontal(|ui| {
ui.label(t!("Seiten:"));
layout_picker(ui, &mut state.new_notebook_layout, true);
layout_picker(ui, &mut state.new_notebook_layout, true, false);
});
ui.add_space(8.0);
@@ -5027,6 +5193,112 @@ mod tests {
ctx
}
#[test]
fn test_page_scrollbar_jumps_through_the_notebook() {
// Layout: Griff oben am Anfang, unten am Ende, gegenüber der Werkzeugleiste
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 850.0));
let (track, top) =
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 0.0, 1000.0);
assert_eq!(track.right(), screen.right());
assert_eq!(top.top(), track.top());
assert!(top.height() >= 48.0);
let (_, end) =
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 80_000.0, 1000.0);
assert_eq!(end.bottom(), track.bottom());
let (left, _) = scrollbar_layout(screen, SidebarSide::Right, (0.0, 1.0), 0.0, 1.0);
assert_eq!(left.left(), screen.left());
// Notizbuch mit 80 Seiten, durchgehend, Ansicht auf Seite 1
let mut notebook = Notebook::new();
for _ in 1..80 {
notebook.add_page(PageBackground::Blank);
}
let mut state = UiState::new();
state.continuous_scroll = true;
state.page_view = Some(PageView {
origin: egui::pos2(300.0, 0.0),
scale: 0.25,
});
let ctx = render_frames(&mut state, &notebook, 2);
let updater = crate::updater::UpdaterController::new();
let x = screen.right() - 14.0;
let mut actions = Vec::new();
// Griff greifen und bis ans untere Ende ziehen
for (pos, pressed) in [
(egui::pos2(x, 30.0), Some(true)),
(egui::pos2(x, 400.0), None),
(egui::pos2(x, 845.0), None),
(egui::pos2(x, 845.0), Some(false)),
] {
let mut events = vec![egui::Event::PointerMoved(pos)];
if let Some(pressed) = pressed {
events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
}
ctx.begin_frame(egui::RawInput {
screen_rect: Some(screen),
events,
..Default::default()
});
actions.extend(render_ui(
&ctx,
&mut state,
&notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
let targets: Vec<f32> = actions
.iter()
.filter_map(|a| match a {
UiAction::ScrollTo(y) => Some(*y),
_ => None,
})
.collect();
let (_, max) = notebook.page_stack().bounds();
assert!(targets.len() >= 2, "{actions:?}");
assert!(targets.windows(2).all(|w| w[1] >= w[0]));
assert!((targets.last().unwrap() - max.y).abs() < 1.0, "{targets:?}");
assert!(state.scrollbar_grab.is_none());
// Feste Seiten: keine Leiste, Tippen an den Rand scrollt nicht
state.continuous_scroll = false;
let ctx = render_frames(&mut state, &notebook, 2);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(x, 400.0));
assert!(!actions.iter().any(|a| matches!(a, UiAction::ScrollTo(_))));
}
#[test]
fn test_ctrl_plus_does_not_zoom_the_ui() {
let ctx = egui::Context::default();
configure_context(&ctx);
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
for _ in 0..2 {
ctx.begin_frame(egui::RawInput {
events: vec![egui::Event::Key {
key: egui::Key::Plus,
physical_key: None,
pressed: true,
repeat: false,
modifiers: ctrl,
}],
modifiers: ctrl,
..Default::default()
});
let _ = ctx.end_frame();
}
assert_eq!(ctx.zoom_factor(), 1.0);
}
#[test]
fn test_text_edit_overlay_takes_typing_and_escape() {
let notebook = Notebook::new();
@@ -5569,6 +5841,11 @@ mod tests {
// Eingerastete Form (Formwerkzeug oder Halten am Strichende)
assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape);
assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape);
// Eingerasteter Textmarker (Issue #32)
assert_eq!(
ActiveTool::Highlighter.stroke_tool(true),
StrokeTool::Highlighter
);
}
#[test]
+154
View File
@@ -305,6 +305,102 @@ impl ViewportState {
}
}
/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der
/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab.
/// Geschwindigkeiten in Bildschirmpixeln je Sekunde.
#[derive(Debug, Clone, Default)]
pub struct Fling {
velocity: Vec2,
/// Fingerweg (aufsummiert) der letzten [`Self::WINDOW`] mit Zeitpunkt
trail: std::collections::VecDeque<(std::time::Instant, Vec2)>,
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
last: Option<std::time::Instant>,
active: bool,
}
impl Fling {
/// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION)
const FRICTION: f32 = 2.5;
/// Langsamer wischen gleitet nicht nach
const MIN_START_SPEED: f32 = 250.0;
/// Darunter hört das Gleiten auf
const MIN_SPEED: f32 = 40.0;
/// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(120);
/// Die Geschwindigkeit ergibt sich aus dem Weg in dieser Zeit vor dem Abheben. Über
/// mehrere Ereignisse gemessen, weil Touchscreens kurz vor dem Abheben oft nur noch
/// winzige Bewegungen melden und Ereignisse gebündelt ankommen, wenn ein Bild länger
/// zum Zeichnen braucht.
const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
/// Finger hat sich um `delta` verschoben.
pub fn track(&mut self, delta: Vec2, now: std::time::Instant) {
self.active = false;
let pos = self.trail.back().map_or(Vec2::ZERO, |&(_, p)| p) + delta;
self.trail.push_back((now, pos));
// Einen Punkt vor dem Fenster behalten, damit es ganz abgedeckt ist
while self.trail.len() > 2 && now.duration_since(self.trail[1].0) > Self::WINDOW {
self.trail.pop_front();
}
if delta != Vec2::ZERO {
self.last = Some(now);
}
}
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. Gibt die
/// gemessene Geschwindigkeit zurück (für die Fehlersuche).
pub fn release(&mut self, now: std::time::Instant) -> Vec2 {
let resting = self
.last
.is_none_or(|last| now.duration_since(last) > Self::MAX_REST);
self.velocity = match (self.trail.front(), self.trail.back()) {
(Some(&(t0, p0)), Some(&(_, p1))) => {
// Bis zum Abheben gemessen; gebündelte Ereignisse zählen mindestens 10 ms
let dt = now.duration_since(t0).as_secs_f32().max(0.01);
(p1 - p0) / dt
}
_ => Vec2::ZERO,
};
self.trail.clear();
let velocity = self.velocity;
self.active = !resting && velocity.length() >= Self::MIN_START_SPEED;
self.last = Some(now);
if !self.active {
self.stop();
}
velocity
}
/// Hält an (neue Berührung, Mausklick, Mausrad).
pub fn stop(&mut self) {
*self = Self::default();
}
pub fn is_active(&self) -> bool {
self.active
}
/// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet.
pub fn step(&mut self, now: std::time::Instant) -> Option<Vec2> {
if !self.active {
return None;
}
let dt = self
.last
.map_or(0.0, |last| now.duration_since(last).as_secs_f32())
.min(0.1);
self.last = Some(now);
// Exakt integriert, damit die Strecke nicht von der Bildrate abhängt
let decay = (-Self::FRICTION * dt).exp();
let delta = self.velocity * (1.0 - decay) / Self::FRICTION;
self.velocity *= decay;
if self.velocity.length() < Self::MIN_SPEED {
self.stop();
}
Some(delta)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -316,6 +412,64 @@ mod tests {
PageStack::new(vec![A4; count], current)
}
#[test]
fn test_fling_glides_and_slows_down() {
use std::time::{Duration, Instant};
let t0 = Instant::now();
let ms = |n: u64| t0 + Duration::from_millis(n);
let mut fling = Fling::default();
// Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s
for i in 0..=10 {
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
fling.release(ms(105));
assert!(fling.is_active());
let first = fling.step(ms(121)).unwrap();
assert!(first.y < -20.0, "{first:?}");
let mut total = first;
let mut t = 121;
while let Some(d) = fling.step(ms(t)) {
total += d;
t += 16;
assert!(t < 10_000, "gleitet endlos");
}
// Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss
assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}");
assert!(!fling.is_active());
// Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten
let mut slow = Fling::default();
for i in 0..=10 {
slow.track(Vec2::new(0.0, -2.0), ms(i * 10));
}
slow.release(ms(105));
assert!(!slow.is_active());
let mut rested = Fling::default();
for i in 0..=10 {
rested.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
rested.release(ms(400));
assert!(!rested.is_active());
// Touchscreens melden kurz vor dem Abheben oft nur noch winzige Bewegungen
let mut lifted = Fling::default();
for i in 0..=8 {
lifted.track(Vec2::new(0.0, -30.0), ms(i * 10));
}
lifted.track(Vec2::new(0.0, -1.0), ms(90));
lifted.track(Vec2::new(0.0, 0.0), ms(100));
lifted.release(ms(104));
assert!(lifted.is_active());
// Gebündelt ankommende Ereignisse (ein Bild brauchte 50 ms)
let mut batched = Fling::default();
for t in [0, 50, 50, 50, 50, 100, 100, 100, 100] {
batched.track(Vec2::new(0.0, -20.0), ms(t));
}
batched.release(ms(100));
assert!(batched.is_active());
}
#[test]
fn test_continuous_layout() {
let stack = a4_stack(5, 1);