Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f212b2e23f | ||
|
|
61c0c903a4 | ||
|
|
680fa96f32 | ||
|
|
eb0a801921 | ||
|
|
76ecdac081 | ||
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -22,12 +22,13 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
|
||||
- **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.
|
||||
|
||||
### Formerkennung
|
||||
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper).
|
||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
||||
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
||||
|
||||
### Auswählen und Bearbeiten
|
||||
|
||||
@@ -136,12 +136,13 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
||||
|
||||
## 6. Formerkennung
|
||||
|
||||
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
||||
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
||||
|
||||
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
||||
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
||||
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
||||
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
||||
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
|
||||
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
||||
|
||||
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `sample
|
||||
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
|
||||
```
|
||||
|
||||
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`.
|
||||
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
|
||||
|
||||
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **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
|
||||
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
|
||||
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
|
||||
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
|
||||
|
||||
@@ -1033,6 +1033,59 @@ fn render_recognized_shape(
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||
} => {
|
||||
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let to_pdf = |p: &glam::Vec2| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
};
|
||||
let mut heads = Vec::new();
|
||||
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||
let dir = (pos - neg).normalize_or_zero();
|
||||
let pos_head = crate::geometry::arrow_head(
|
||||
pos,
|
||||
dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(pos - *origin).length(),
|
||||
);
|
||||
let neg_head = crate::geometry::arrow_head(
|
||||
neg,
|
||||
-dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(neg - *origin).length(),
|
||||
);
|
||||
let (from, to) = if *with_arrows {
|
||||
(neg_head.0, pos_head.0)
|
||||
} else {
|
||||
(neg, pos)
|
||||
};
|
||||
layer.add_line(Line {
|
||||
points: [from, to].iter().map(to_pdf).collect(),
|
||||
is_closed: false,
|
||||
});
|
||||
heads.push((pos, pos_head));
|
||||
heads.push((neg, neg_head));
|
||||
}
|
||||
if *with_arrows {
|
||||
layer.set_fill_color(stroke_color);
|
||||
for (tip, (_, left, right)) in heads {
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![[tip, left, right].iter().map(to_pdf).collect()],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: None,
|
||||
} => {
|
||||
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||
@@ -1450,6 +1503,7 @@ mod tests {
|
||||
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
||||
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
},
|
||||
);
|
||||
page.add_stroke(coords_stroke);
|
||||
|
||||
+342
-1
@@ -59,6 +59,11 @@ pub enum RecognizedShape {
|
||||
x_axis_end: Vec2,
|
||||
y_axis_end: Vec2,
|
||||
with_arrows: bool,
|
||||
/// Achsenkreuz: Enden der x- und der y-Achse auf der anderen Seite des Ursprungs
|
||||
/// (Pfeilspitzen an allen vier Enden). Fehlt es, ist es das L-förmige
|
||||
/// Koordinatensystem; ältere Versionen ignorieren das Feld und zeigen das L.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
negative_ends: Option<[Vec2; 2]>,
|
||||
},
|
||||
Triangle {
|
||||
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
||||
@@ -105,6 +110,10 @@ impl RecognizedShape {
|
||||
Self::Arrow { double: false, .. } => "Pfeil",
|
||||
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
||||
Self::Rectangle { .. } => "Rechteck",
|
||||
Self::CoordinateSystem {
|
||||
negative_ends: Some(_),
|
||||
..
|
||||
} => "Achsenkreuz",
|
||||
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
||||
Self::Triangle { .. } => "Dreieck",
|
||||
Self::Rhombus { .. } => "Raute (Rhombus)",
|
||||
@@ -146,11 +155,15 @@ impl RecognizedShape {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*origin += delta;
|
||||
*x_axis_end += delta;
|
||||
*y_axis_end += delta;
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end += delta;
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -203,11 +216,15 @@ impl RecognizedShape {
|
||||
origin: o,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*o = rot(*o);
|
||||
*x_axis_end = rot(*x_axis_end);
|
||||
*y_axis_end = rot(*y_axis_end);
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end = rot(*end);
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -267,11 +284,15 @@ impl RecognizedShape {
|
||||
origin: o,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*o = sc(*o);
|
||||
*x_axis_end = sc(*x_axis_end);
|
||||
*y_axis_end = sc(*y_axis_end);
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end = sc(*end);
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -1394,6 +1415,87 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
x_axis_end: x_end,
|
||||
y_axis_end: y_end,
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Achsenkreuz in einem Strich („+“, z. B. waagerecht hin und zurück, dann senkrecht): Alle
|
||||
/// Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, und beide reichen
|
||||
/// auf beiden Seiten deutlich über den Schnittpunkt hinaus. Ergebnis wie beim
|
||||
/// Koordinatensystem achsenparallel, mit Pfeilspitzen an allen vier Enden.
|
||||
pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
if points.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let aabb = Aabb::from_points(points);
|
||||
let diagonal = (aabb.max - aabb.min).length();
|
||||
if diagonal < 60.0 {
|
||||
return None;
|
||||
}
|
||||
let simplified = ramer_douglas_peucker(points, (diagonal * 0.05).clamp(8.0, 40.0));
|
||||
|
||||
// Teilstücke nach Richtung: waagerecht oder senkrecht (höchstens 25° schief); kurze
|
||||
// Stücke (Umkehrpunkte) zählen nicht, schräge lange Stücke schließen ein Kreuz aus
|
||||
let max_slope = 25.0_f32.to_radians().tan();
|
||||
let (mut horizontal, mut vertical) = ((0.0, 0.0), (0.0, 0.0));
|
||||
for w in simplified.windows(2) {
|
||||
let d = w[1] - w[0];
|
||||
let len = d.length();
|
||||
if len < diagonal * 0.08 {
|
||||
continue;
|
||||
}
|
||||
let mid = (w[0] + w[1]) * 0.5;
|
||||
if d.y.abs() <= d.x.abs() * max_slope {
|
||||
horizontal = (horizontal.0 + mid.y * len, horizontal.1 + len);
|
||||
} else if d.x.abs() <= d.y.abs() * max_slope {
|
||||
vertical = (vertical.0 + mid.x * len, vertical.1 + len);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if horizontal.1 == 0.0 || vertical.1 == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let origin = Vec2::new(vertical.0 / vertical.1, horizontal.0 / horizontal.1);
|
||||
|
||||
// Jeder Punkt liegt auf einer der beiden Linien
|
||||
let tolerance = (diagonal * 0.08).max(10.0);
|
||||
let (mut x_min, mut x_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
let (mut y_min, mut y_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for p in points {
|
||||
let on_horizontal = (p.y - origin.y).abs() <= tolerance;
|
||||
let on_vertical = (p.x - origin.x).abs() <= tolerance;
|
||||
if !on_horizontal && !on_vertical {
|
||||
return None;
|
||||
}
|
||||
if on_horizontal {
|
||||
x_min = x_min.min(p.x);
|
||||
x_max = x_max.max(p.x);
|
||||
}
|
||||
if on_vertical {
|
||||
y_min = y_min.min(p.y);
|
||||
y_max = y_max.max(p.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Vier Arme: jeder mindestens ein Fünftel seiner Achse und deutlich länger als die Toleranz
|
||||
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2 && arm >= tolerance * 1.5;
|
||||
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||
if !(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)]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1423,7 +1525,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 7] = [
|
||||
// Das Achsenkreuz zuerst: Seine Prüfung ist streng, und ein hin und her gezeichnetes
|
||||
// „+“ könnte sonst als Vieleck durchgehen
|
||||
&|| detect_axis_cross(&pts),
|
||||
&|| {
|
||||
if closed_fits_well {
|
||||
None
|
||||
@@ -1908,6 +2013,51 @@ pub fn shape_to_mesh(
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||
} => {
|
||||
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||
let mut vertices = Vec::new();
|
||||
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||
let dir = (pos - neg).normalize_or_zero();
|
||||
let pos_head = arrow_head(pos, dir, half_w, (pos - *origin).length());
|
||||
let neg_head = arrow_head(neg, -dir, half_w, (neg - *origin).length());
|
||||
let (from, to) = if *with_arrows {
|
||||
(neg_head.0, pos_head.0)
|
||||
} else {
|
||||
(neg, pos)
|
||||
};
|
||||
push_band(&mut vertices, from, to, half_w, color);
|
||||
if *with_arrows {
|
||||
for (tip, (_, left, right)) in [(pos, pos_head), (neg, neg_head)] {
|
||||
push_triangle(&mut vertices, tip, left, right, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Abtastpunkte als Linienzug ohne Diagonalen (jeder Arm vom Ursprung aus)
|
||||
let samples = [
|
||||
*x_axis_start,
|
||||
*origin,
|
||||
*x_axis_end,
|
||||
*origin,
|
||||
*y_axis_end,
|
||||
*origin,
|
||||
*y_axis_start,
|
||||
]
|
||||
.map(|position| StylusSample {
|
||||
position,
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.to_vec();
|
||||
(samples, vertices)
|
||||
}
|
||||
|
||||
RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: None,
|
||||
} => {
|
||||
let mut vertices = Vec::new();
|
||||
let mut samples = Vec::new();
|
||||
@@ -2012,6 +2162,40 @@ pub fn shape_to_mesh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Hängt ein gerades Band der halben Breite `half_w` von `a` nach `b` an einen Triangle-Strip
|
||||
/// an (über degenerierte Dreiecke, falls er schon Vertizes hat).
|
||||
fn push_band(vertices: &mut Vec<InkVertex>, a: Vec2, b: Vec2, half_w: f32, color: [f32; 4]) {
|
||||
let dir = (b - a).normalize_or_zero();
|
||||
let norm = Vec2::new(-dir.y, dir.x) * half_w;
|
||||
let first = InkVertex::new(a.x + norm.x, a.y + norm.y, color);
|
||||
if let Some(&last) = vertices.last() {
|
||||
vertices.push(last);
|
||||
vertices.push(first);
|
||||
}
|
||||
vertices.push(first);
|
||||
vertices.push(InkVertex::new(a.x - norm.x, a.y - norm.y, color));
|
||||
vertices.push(InkVertex::new(b.x + norm.x, b.y + norm.y, color));
|
||||
vertices.push(InkVertex::new(b.x - norm.x, b.y - norm.y, color));
|
||||
}
|
||||
|
||||
/// Hängt ein Dreieck (Pfeilspitze) an einen Triangle-Strip an.
|
||||
fn push_triangle(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
tip: Vec2,
|
||||
left: Vec2,
|
||||
right: Vec2,
|
||||
color: [f32; 4],
|
||||
) {
|
||||
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||
if let Some(&last) = vertices.last() {
|
||||
vertices.push(last);
|
||||
vertices.push(tip_v);
|
||||
}
|
||||
vertices.push(tip_v);
|
||||
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||
}
|
||||
|
||||
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
||||
pub fn shape_to_stroke(
|
||||
shape: RecognizedShape,
|
||||
@@ -2210,6 +2394,7 @@ mod tests {
|
||||
x_axis_end: Vec2::new(900.0, 900.0),
|
||||
y_axis_end: Vec2::new(300.0, 300.0),
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
};
|
||||
let width = 30.0;
|
||||
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
||||
@@ -2479,12 +2664,167 @@ mod tests {
|
||||
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
||||
Some(RecognizedShape::CoordinateSystem {
|
||||
negative_ends: Some(_),
|
||||
..
|
||||
}) => "Achsenkreuz",
|
||||
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
||||
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
||||
None => "nichts",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_axis_cross_in_one_stroke() {
|
||||
let c = Vec2::new(800.0, 900.0);
|
||||
let v = |x: f32, y: f32| c + Vec2::new(x, y);
|
||||
// Verschiedene Zeichenreihenfolgen eines „+“ in einem Zug
|
||||
let crosses: [(&str, Vec<Vec2>); 4] = [
|
||||
(
|
||||
"waagerecht hin, zurück zur Mitte, senkrecht",
|
||||
vec![
|
||||
v(-200.0, 0.0),
|
||||
v(200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, -150.0),
|
||||
v(0.0, 150.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"senkrecht zuerst",
|
||||
vec![
|
||||
v(0.0, -180.0),
|
||||
v(0.0, 180.0),
|
||||
v(0.0, 0.0),
|
||||
v(-220.0, 0.0),
|
||||
v(220.0, 0.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Arme ungleich lang",
|
||||
vec![
|
||||
v(-100.0, 0.0),
|
||||
v(320.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, 250.0),
|
||||
v(0.0, -120.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Mitte zuerst, alle Arme hin und zurück",
|
||||
vec![
|
||||
v(0.0, 0.0),
|
||||
v(200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, -200.0),
|
||||
v(0.0, 0.0),
|
||||
v(-200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, 200.0),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (seed, (name, outline)) in crosses.iter().enumerate() {
|
||||
let samples = freehand(outline, false, seed as f32);
|
||||
let shape = detect_shape(&samples);
|
||||
assert_eq!(kind(&shape), "Achsenkreuz", "{name}");
|
||||
let Some(RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends: Some([x_start, y_start]),
|
||||
with_arrows: true,
|
||||
}) = shape
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!((origin - c).length() < 8.0, "{name}: Ursprung {origin}");
|
||||
// Achsenparallel, x nach rechts, y nach oben
|
||||
assert_eq!(x_axis_end.y, origin.y);
|
||||
assert_eq!(x_start.y, origin.y);
|
||||
assert_eq!(y_axis_end.x, origin.x);
|
||||
assert!(x_axis_end.x > origin.x && x_start.x < origin.x, "{name}");
|
||||
assert!(y_axis_end.y < origin.y && y_start.y > origin.y, "{name}");
|
||||
}
|
||||
|
||||
// Keine Kreuze: L-Form, T-Form, einzelne Linie, Rechteck
|
||||
let not_crosses: [(&str, Vec<Vec2>, bool); 4] = [
|
||||
("L", vec![v(0.0, -200.0), v(0.0, 0.0), v(250.0, 0.0)], false),
|
||||
(
|
||||
"T",
|
||||
vec![v(-200.0, 0.0), v(200.0, 0.0), v(0.0, 0.0), v(0.0, 200.0)],
|
||||
false,
|
||||
),
|
||||
("Linie", vec![v(-200.0, 0.0), v(200.0, 0.0)], false),
|
||||
(
|
||||
"Rechteck",
|
||||
vec![
|
||||
v(-200.0, -100.0),
|
||||
v(200.0, -100.0),
|
||||
v(200.0, 100.0),
|
||||
v(-200.0, 100.0),
|
||||
],
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (seed, (name, outline, closed)) in not_crosses.iter().enumerate() {
|
||||
let samples = freehand(outline, *closed, seed as f32 + 10.0);
|
||||
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||
assert!(
|
||||
detect_axis_cross(&points).is_none(),
|
||||
"{name} ist kein Kreuz"
|
||||
);
|
||||
assert_ne!(kind(&detect_shape(&samples)), "Achsenkreuz", "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_axis_cross_mesh_and_transforms() {
|
||||
let mut shape = RecognizedShape::CoordinateSystem {
|
||||
origin: Vec2::new(500.0, 500.0),
|
||||
x_axis_end: Vec2::new(800.0, 500.0),
|
||||
y_axis_end: Vec2::new(500.0, 300.0),
|
||||
with_arrows: true,
|
||||
negative_ends: Some([Vec2::new(300.0, 500.0), Vec2::new(500.0, 700.0)]),
|
||||
};
|
||||
assert_eq!(shape.name(), "Achsenkreuz");
|
||||
let (samples, vertices) = shape_to_mesh(&shape, 10.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
// Alle vier Pfeilspitzen sind Eckpunkte des Netzes
|
||||
for tip in [
|
||||
(800.0, 500.0),
|
||||
(500.0, 300.0),
|
||||
(300.0, 500.0),
|
||||
(500.0, 700.0),
|
||||
] {
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.any(|v| (Vec2::from(v.position) - Vec2::from(tip)).length() < 1e-3),
|
||||
"Spitze {tip:?} fehlt"
|
||||
);
|
||||
}
|
||||
// Die Form passt zu sich selbst gezeichnet (Abtastpunkte ohne Diagonalen)
|
||||
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||
assert!(shape_fit_error(&points, &shape).unwrap() < 1e-3);
|
||||
// Verschieben bewegt auch die negativen Enden
|
||||
shape.translate(Vec2::new(10.0, 20.0));
|
||||
let RecognizedShape::CoordinateSystem {
|
||||
negative_ends: Some([x_start, y_start]),
|
||||
..
|
||||
} = shape
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(x_start, Vec2::new(310.0, 520.0));
|
||||
assert_eq!(y_start, Vec2::new(510.0, 720.0));
|
||||
// Ältere Dateien ohne das Feld bleiben L-Koordinatensysteme; neue Dateien enthalten es
|
||||
let json = serde_json::to_string(&shape).unwrap();
|
||||
assert!(json.contains("negative_ends"));
|
||||
let old = r#"{"CoordinateSystem":{"origin":[0.0,0.0],"x_axis_end":[1.0,0.0],"y_axis_end":[0.0,-1.0],"with_arrows":true}}"#;
|
||||
let parsed: RecognizedShape = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(parsed.name(), "Koordinatensystem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_shape_freehand_classification() {
|
||||
let c = Vec2::new(1000.0, 1400.0);
|
||||
@@ -2884,6 +3224,7 @@ mod tests {
|
||||
x_axis_end: x_end,
|
||||
y_axis_end: y_end,
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
};
|
||||
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
use crate::document::Stroke;
|
||||
|
||||
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
||||
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
|
||||
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HistoryAction {
|
||||
/// Ein Strich wurde neu gezeichnet.
|
||||
|
||||
+102
-33
@@ -30,11 +30,39 @@ use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||
use ink_notes::settings::{
|
||||
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
|
||||
};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// 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;
|
||||
match tool {
|
||||
ActiveTool::Pen | ActiveTool::Shape => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Highlighter => {
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet-
|
||||
/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung).
|
||||
fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
// DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein.
|
||||
fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) {
|
||||
ui_state.available_notebooks = storage::list_notebooks(base_dir);
|
||||
@@ -1107,6 +1135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||
ui_state.select_pen(ui_settings.active_pen);
|
||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||
@@ -1186,7 +1215,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut is_stylus_selecting = false;
|
||||
let mut is_stylus_in_proximity = false;
|
||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||
let mut stylus_barrel_pressed = false;
|
||||
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||
// 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;
|
||||
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;
|
||||
@@ -1307,6 +1339,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(tablet_events) = mgr.pump() {
|
||||
let scale = window.scale_factor() as f32;
|
||||
let now = start_time.elapsed().as_secs_f64();
|
||||
// Aufsetzen erst mit der Position desselben Frames verarbeiten
|
||||
let tablet_events = ink_notes::platform::down_after_pose(
|
||||
tablet_events,
|
||||
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Down, .. }),
|
||||
|e| matches!(e, octotablet::events::Event::Tool { event: octotablet::events::ToolEvent::Pose(_), .. }),
|
||||
);
|
||||
|
||||
for t_event in tablet_events {
|
||||
if let octotablet::events::Event::Tool { tool, event } = t_event {
|
||||
@@ -1357,23 +1395,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
if let Some(tool) = tool_before_button.take() {
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
}
|
||||
stylus_held = None;
|
||||
ui_state.pressed_stylus_button = None;
|
||||
}
|
||||
}
|
||||
octotablet::events::ToolEvent::Button { pressed, .. } => {
|
||||
stylus_barrel_pressed = pressed;
|
||||
if pressed && is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
is_drawing = false;
|
||||
canvas.cancel_stroke();
|
||||
is_erasing = true;
|
||||
window.request_redraw();
|
||||
} else if !pressed
|
||||
&& is_erasing
|
||||
&& !tool_is_hardware_eraser
|
||||
&& ui_state.active_tool != ActiveTool::Eraser
|
||||
{
|
||||
is_erasing = false;
|
||||
octotablet::events::ToolEvent::Button { button_id, pressed } => {
|
||||
// Tasten werden beim ersten Drücken gelernt (Standard: Radieren)
|
||||
let key = stylus_button_key(&button_id);
|
||||
let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key);
|
||||
if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS {
|
||||
ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase });
|
||||
index = Some(ui_state.stylus_buttons.len() - 1);
|
||||
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action);
|
||||
if pressed {
|
||||
stylus_held = Some((key, action));
|
||||
ui_state.pressed_stylus_button = index;
|
||||
if action == StylusAction::Erase && is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
is_drawing = false;
|
||||
canvas.cancel_stroke();
|
||||
is_erasing = true;
|
||||
}
|
||||
} else if stylus_held.is_some_and(|(held, _)| held == key) {
|
||||
stylus_held = None;
|
||||
ui_state.pressed_stylus_button = None;
|
||||
if action == StylusAction::Erase
|
||||
&& is_erasing
|
||||
&& !tool_is_hardware_eraser
|
||||
&& ui_state.active_tool != ActiveTool::Eraser
|
||||
{
|
||||
is_erasing = false;
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
octotablet::events::ToolEvent::Pose(pose) => {
|
||||
is_stylus_in_proximity = true;
|
||||
@@ -1471,8 +1531,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
focus_page_at!((sx, sy));
|
||||
// Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug
|
||||
let button_action = stylus_held.map(|(_, action)| action);
|
||||
let button_tool = match button_action {
|
||||
Some(StylusAction::Select) => Some(ActiveTool::Select),
|
||||
Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter),
|
||||
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tool) = button_tool
|
||||
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
|
||||
{
|
||||
tool_before_button = Some(ui_state.active_tool);
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
}
|
||||
let should_erase = tool_is_hardware_eraser
|
||||
|| stylus_barrel_pressed
|
||||
|| button_action == Some(StylusAction::Erase)
|
||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||
@@ -1550,6 +1624,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
let restore_tool = tool_before_button.take();
|
||||
|
||||
if is_stylus_drawing {
|
||||
is_stylus_drawing = false;
|
||||
@@ -1584,6 +1659,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
snapped_shape,
|
||||
);
|
||||
}
|
||||
// Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug
|
||||
if let Some(tool) = restore_tool {
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2731,22 +2811,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
for action in actions {
|
||||
match action {
|
||||
UiAction::SetTool(tool) => {
|
||||
ui_state.active_tool = tool;
|
||||
match tool {
|
||||
ActiveTool::Pen => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Highlighter => {
|
||||
canvas.set_stroke_color(ui_state.highlighter_color);
|
||||
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Shape => {
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
}
|
||||
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||
}
|
||||
activate_tool(tool, &mut ui_state, &mut canvas);
|
||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3433,6 +3498,10 @@ 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::SaveStylusButtons => {
|
||||
ui_settings.stylus_buttons = ui_state.stylus_buttons.clone();
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
UiAction::SavePressureCurve(curve) => {
|
||||
ui_settings.pressure_curve = curve;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
|
||||
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
|
||||
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
|
||||
/// vor dem nächsten anderen Ereignis.
|
||||
pub fn down_after_pose<T>(
|
||||
events: impl IntoIterator<Item = T>,
|
||||
is_down: impl Fn(&T) -> bool,
|
||||
is_pose: impl Fn(&T) -> bool,
|
||||
) -> Vec<T> {
|
||||
let mut ordered = Vec::new();
|
||||
let mut deferred = None;
|
||||
for event in events {
|
||||
if is_down(&event) {
|
||||
if let Some(down) = deferred.replace(event) {
|
||||
ordered.push(down);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let pose = is_pose(&event);
|
||||
if !pose {
|
||||
ordered.extend(deferred.take());
|
||||
}
|
||||
ordered.push(event);
|
||||
if pose {
|
||||
ordered.extend(deferred.take());
|
||||
}
|
||||
}
|
||||
ordered.extend(deferred);
|
||||
ordered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_down_follows_pose_of_its_frame() {
|
||||
let order = |events: &str| {
|
||||
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
|
||||
.into_iter()
|
||||
.collect::<String>()
|
||||
};
|
||||
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
|
||||
assert_eq!(order("IDPBF"), "IPDBF");
|
||||
// Down ohne Pose im Frame: vor dem Frame-Ende
|
||||
assert_eq!(order("DF"), "DF");
|
||||
assert_eq!(order("DBF"), "DBF");
|
||||
// Am Ende ohne weiteres Ereignis
|
||||
assert_eq!(order("PD"), "PD");
|
||||
// Andere Ereignisse bleiben unverändert
|
||||
assert_eq!(order("PFPFUF"), "PFPFUF");
|
||||
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
|
||||
@@ -150,6 +150,53 @@ fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
||||
out
|
||||
}
|
||||
|
||||
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum StylusAction {
|
||||
/// Radieren (Standard, wie bisher)
|
||||
#[default]
|
||||
Erase,
|
||||
/// Auswählen und verschieben
|
||||
Select,
|
||||
/// Mit dem Textmarker schreiben
|
||||
Highlighter,
|
||||
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
|
||||
Pen,
|
||||
/// Taste nicht verwenden
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl StylusAction {
|
||||
pub const ALL: [StylusAction; 5] = [
|
||||
StylusAction::Erase,
|
||||
StylusAction::Select,
|
||||
StylusAction::Highlighter,
|
||||
StylusAction::Pen,
|
||||
StylusAction::Nothing,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StylusAction::Erase => "Radieren",
|
||||
StylusAction::Select => "Auswählen",
|
||||
StylusAction::Highlighter => "Textmarker",
|
||||
StylusAction::Pen => "Stift",
|
||||
StylusAction::Nothing => "Nichts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
|
||||
/// und ihre Belegung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StylusButton {
|
||||
pub id: u64,
|
||||
pub action: StylusAction,
|
||||
}
|
||||
|
||||
/// Höchstens so viele Stifttasten werden gemerkt.
|
||||
pub const MAX_STYLUS_BUTTONS: usize = 4;
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -173,6 +220,8 @@ pub struct UiSettings {
|
||||
pub active_highlighter: usize,
|
||||
pub text_colors: [[f32; 4]; 5],
|
||||
pub active_text_color: usize,
|
||||
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
|
||||
pub stylus_buttons: Vec<StylusButton>,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -190,6 +239,7 @@ impl Default for UiSettings {
|
||||
active_highlighter: 0,
|
||||
text_colors: DEFAULT_TEXT_COLORS,
|
||||
active_text_color: 0,
|
||||
stylus_buttons: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +287,7 @@ impl UiSettings {
|
||||
settings.active_text_color = settings
|
||||
.active_text_color
|
||||
.min(settings.text_colors.len() - 1);
|
||||
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -277,6 +328,16 @@ mod tests {
|
||||
active_highlighter: 3,
|
||||
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||
active_text_color: 4,
|
||||
stylus_buttons: vec![
|
||||
StylusButton {
|
||||
id: 17,
|
||||
action: StylusAction::Select,
|
||||
},
|
||||
StylusButton {
|
||||
id: 42,
|
||||
action: StylusAction::Nothing,
|
||||
},
|
||||
],
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
@@ -288,6 +288,8 @@ pub enum UiAction {
|
||||
theme: PaperTheme,
|
||||
adapt_ink: bool,
|
||||
},
|
||||
/// Belegung der Stifttasten speichern (gilt sofort über `UiState::stylus_buttons`)
|
||||
SaveStylusButtons,
|
||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||
@@ -376,6 +378,9 @@ pub struct UiState {
|
||||
pub show_color_picker: bool,
|
||||
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
||||
pub context_menu: Option<ContextMenu>,
|
||||
/// 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>,
|
||||
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
|
||||
pub confirm_delete_page: bool,
|
||||
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
||||
@@ -471,6 +476,8 @@ impl Default for UiState {
|
||||
is_text_color_menu_open: false,
|
||||
show_color_picker: false,
|
||||
context_menu: None,
|
||||
stylus_buttons: Vec::new(),
|
||||
pressed_stylus_button: None,
|
||||
confirm_delete_page: false,
|
||||
restorable_page: None,
|
||||
shutdown_error: None,
|
||||
@@ -1635,6 +1642,70 @@ fn pattern_color_button(
|
||||
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
|
||||
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
|
||||
/// (Regler losgelassen oder per Tastatur geändert).
|
||||
/// Belegung der Tasten am Stift. Tasten erscheinen, sobald sie einmal gedrückt wurden; die
|
||||
/// gerade gedrückte ist markiert. `true`, wenn sich etwas geändert hat.
|
||||
fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool {
|
||||
use crate::settings::StylusAction;
|
||||
let mut changed = false;
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Tasten am Stift").color(egui::Color32::from_gray(200)));
|
||||
if state.stylus_buttons.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \
|
||||
Bis dahin radieren alle Tasten.",
|
||||
)
|
||||
.size(11.0)
|
||||
.color(egui::Color32::from_gray(150)),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut remove = None;
|
||||
for i in 0..state.stylus_buttons.len() {
|
||||
ui.horizontal(|ui| {
|
||||
let pressed = state.pressed_stylus_button == Some(i);
|
||||
let label = egui::RichText::new(format!("Taste {}", i + 1));
|
||||
ui.label(if pressed {
|
||||
label.strong().color(egui::Color32::from_rgb(46, 204, 113))
|
||||
} else {
|
||||
label
|
||||
})
|
||||
.on_hover_text("Leuchtet grün, solange die Taste gedrückt ist");
|
||||
let current = state.stylus_buttons[i].action;
|
||||
egui::ComboBox::from_id_source(("stylus_button", i))
|
||||
.selected_text(current.label())
|
||||
.width(130.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for action in StylusAction::ALL {
|
||||
if ui
|
||||
.selectable_label(action == current, action.label())
|
||||
.clicked()
|
||||
&& action != current
|
||||
{
|
||||
state.stylus_buttons[i].action = action;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui
|
||||
.small_button("🗙")
|
||||
.on_hover_text(
|
||||
"Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
state.stylus_buttons.remove(i);
|
||||
state.pressed_stylus_button = None;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
||||
let mut save = false;
|
||||
let mut track = |response: egui::Response| {
|
||||
@@ -3560,6 +3631,9 @@ pub fn render_ui(
|
||||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||
actions.push(UiAction::SavePressureCurve(curve));
|
||||
}
|
||||
if stylus_buttons_editor(ui, state) {
|
||||
actions.push(UiAction::SaveStylusButtons);
|
||||
}
|
||||
|
||||
section(ui, "WERKZEUGLEISTE");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -5295,6 +5369,43 @@ mod tests {
|
||||
actions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stylus_buttons_in_menu() {
|
||||
use crate::settings::{StylusAction, StylusButton};
|
||||
let notebook = Notebook::new();
|
||||
let mut state = UiState::new();
|
||||
state.is_more_menu_open = true;
|
||||
// Ohne gelernte Tasten: nur der Hinweis, keine Aktion
|
||||
let ctx = egui::Context::default();
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let shapes = ctx
|
||||
.run(egui::RawInput::default(), |ctx| {
|
||||
let _ = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
})
|
||||
.shapes
|
||||
.len();
|
||||
assert!(shapes > 0);
|
||||
|
||||
// Mit zwei Tasten: beide Zeilen, die gedrückte hervorgehoben, ohne Fehler
|
||||
state.stylus_buttons = vec![
|
||||
StylusButton {
|
||||
id: 1,
|
||||
action: StylusAction::Erase,
|
||||
},
|
||||
StylusButton {
|
||||
id: 2,
|
||||
action: StylusAction::Select,
|
||||
},
|
||||
];
|
||||
state.pressed_stylus_button = Some(1);
|
||||
let output = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
let actions = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
assert!(!actions.contains(&UiAction::SaveStylusButtons));
|
||||
});
|
||||
assert!(!output.shapes.is_empty());
|
||||
assert_eq!(state.stylus_buttons.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_press_detection() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
Reference in New Issue
Block a user