Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52e958c7f | ||
|
|
3e2b627d6a | ||
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 |
Generated
+2
-1
@@ -1048,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1057,6 +1057,7 @@ dependencies = [
|
||||
"fs2",
|
||||
"glam",
|
||||
"minisign-verify",
|
||||
"naga",
|
||||
"octotablet",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -53,3 +53,5 @@ strip = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
|
||||
naga = { version = "0.19", features = ["wgsl-in"] }
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
|
||||
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
|
||||
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
|
||||
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||
|
||||
+6
-1
@@ -312,7 +312,12 @@ impl AutosaveController {
|
||||
|
||||
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
||||
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
||||
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
||||
// Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
|
||||
// der Seitendatei überschreiben
|
||||
if self.read_only
|
||||
|| !page.is_loaded
|
||||
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||
{
|
||||
return;
|
||||
}
|
||||
let page_data = PageData::from(page);
|
||||
|
||||
+195
-36
@@ -1,5 +1,7 @@
|
||||
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
|
||||
@@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
|
||||
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
||||
pub const LINED_SPACING: f32 = 75.0;
|
||||
|
||||
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
|
||||
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
|
||||
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
|
||||
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
|
||||
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
|
||||
r: 0.16,
|
||||
g: 0.16,
|
||||
b: 0.18,
|
||||
a: 1.0,
|
||||
};
|
||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||
///
|
||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaperTheme {
|
||||
#[default]
|
||||
White,
|
||||
/// Cremefarben (augenschonend, wie Recyclingpapier)
|
||||
Cream,
|
||||
/// Dunkelgrau (Nachtmodus)
|
||||
DarkGray,
|
||||
/// Schwarz (OLED)
|
||||
Black,
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad() -> Vec<InkVertex> {
|
||||
impl PaperTheme {
|
||||
pub const ALL: [PaperTheme; 4] = [
|
||||
PaperTheme::White,
|
||||
PaperTheme::Cream,
|
||||
PaperTheme::DarkGray,
|
||||
PaperTheme::Black,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaperTheme::White => "Weiß",
|
||||
PaperTheme::Cream => "Creme",
|
||||
PaperTheme::DarkGray => "Dunkelgrau",
|
||||
PaperTheme::Black => "Schwarz",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
|
||||
pub fn is_dark(self) -> bool {
|
||||
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
|
||||
}
|
||||
|
||||
/// Farbe des Blatts.
|
||||
pub fn paper_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
|
||||
// sRGB #FBF5E6
|
||||
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
|
||||
// sRGB #2B2B2E
|
||||
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
|
||||
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Farbe von Karos, Linien und Punkten.
|
||||
pub fn pattern_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
|
||||
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
|
||||
// sRGB #48484D bzw. #38383C
|
||||
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
|
||||
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
|
||||
/// das Blatt abhebt.
|
||||
pub fn desk_color(self) -> wgpu::Color {
|
||||
let (r, g, b) = match self {
|
||||
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
|
||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||
};
|
||||
wgpu::Color { r, g, b, a: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
|
||||
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
|
||||
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
|
||||
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
|
||||
let max = color[0].max(color[1]).max(color[2]);
|
||||
let min = color[0].min(color[1]).min(color[2]);
|
||||
let lift = (1.0 - max - min).max(0.0);
|
||||
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
|
||||
vec![
|
||||
InkVertex::new(0.0, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
|
||||
]
|
||||
}
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
|
||||
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
|
||||
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_t,
|
||||
0.0,
|
||||
x + half_t,
|
||||
PAGE_HEIGHT,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
// Horizontale Linien alle 5 mm
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < PAGE_HEIGHT {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= PAGE_HEIGHT - margin_bottom {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
@@ -119,18 +222,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_paper_quad_generation() {
|
||||
let quad = generate_paper_quad();
|
||||
let quad = generate_paper_quad(PaperTheme::White.paper_color());
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert_eq!(quad[0].position, [0.0, 0.0]);
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank);
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
@@ -138,12 +243,66 @@ mod tests {
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined);
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid);
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.color == PaperTheme::Cream.paper_color()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapt_ink_color() {
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
// Schwarz wird Weiß, Deckkraft bleibt
|
||||
assert!(close(
|
||||
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
|
||||
[1.0, 1.0, 1.0, 0.7]
|
||||
));
|
||||
// Notiz-Schwarz wird hell
|
||||
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
|
||||
assert!(ink[0] > 0.85 && ink[2] > 0.85);
|
||||
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
|
||||
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
|
||||
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
|
||||
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
|
||||
for c in [
|
||||
[0.85, 0.20, 0.20, 1.0],
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
[1.0, 0.95, 0.1, 0.35],
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
] {
|
||||
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
|
||||
}
|
||||
// Ergebnis ist nie heller als Weiß
|
||||
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
|
||||
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paper_themes_contrast() {
|
||||
for theme in PaperTheme::ALL {
|
||||
let paper = theme.paper_color()[0];
|
||||
let pattern = theme.pattern_color()[0];
|
||||
assert!(
|
||||
(paper - pattern).abs() > 0.03,
|
||||
"{theme:?}: Muster hebt sich nicht vom Papier ab"
|
||||
);
|
||||
// Helles Papier ↔ dunkles Papier
|
||||
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+172
-41
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -58,6 +58,8 @@ impl InkVertex {
|
||||
pub struct CameraUniforms {
|
||||
pub transform_scale: [f32; 2],
|
||||
pub transform_offset: [f32; 2],
|
||||
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
|
||||
pub ink_params: [f32; 4],
|
||||
}
|
||||
|
||||
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
||||
@@ -87,8 +89,35 @@ pub fn spline_vertices_to_ndc(
|
||||
}
|
||||
|
||||
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
||||
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
|
||||
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
|
||||
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
|
||||
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
|
||||
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
|
||||
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
|
||||
|
||||
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
|
||||
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
||||
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
|
||||
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
|
||||
let preferred: &[wgpu::PresentMode] = if force_fifo {
|
||||
&[wgpu::PresentMode::Fifo]
|
||||
} else {
|
||||
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
|
||||
};
|
||||
preferred
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|m| available.contains(m))
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
adapter: wgpu::Adapter,
|
||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||
presentation_warned: bool,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
@@ -108,7 +137,6 @@ pub struct CanvasState {
|
||||
pub camera_bind_group: wgpu::BindGroup,
|
||||
pub current_ink_color: [f32; 4],
|
||||
pub current_stroke_width_mm: f32,
|
||||
pub current_tool: crate::document::StrokeTool,
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
@@ -116,6 +144,10 @@ pub struct CanvasState {
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink: bool,
|
||||
|
||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||
pub live_vertex_buffer: wgpu::Buffer,
|
||||
@@ -149,23 +181,47 @@ impl CanvasState {
|
||||
let viewport =
|
||||
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||
|
||||
// 1. WGPU Instance & Surface
|
||||
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::all(),
|
||||
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
|
||||
// 2. Adapter auswählen (hohe Performance bevorzugt)
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.ok_or("Kein passender GPU-Adapter gefunden")?;
|
||||
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
|
||||
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
|
||||
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
|
||||
let mut chosen = None;
|
||||
for force_fallback_adapter in [false, true] {
|
||||
let Some(adapter) = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
match choose_present_mode(&caps.present_modes) {
|
||||
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
|
||||
eprintln!(
|
||||
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
|
||||
info.name, info.backend, mode
|
||||
);
|
||||
chosen = Some((adapter, caps, mode));
|
||||
break;
|
||||
}
|
||||
_ => eprintln!(
|
||||
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
|
||||
info.name, info.backend
|
||||
),
|
||||
}
|
||||
}
|
||||
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
|
||||
|
||||
// 3. Device & Queue erstellen
|
||||
let (device, queue) = adapter
|
||||
@@ -179,8 +235,7 @@ impl CanvasState {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
|
||||
let surface_caps = surface.get_capabilities(&adapter);
|
||||
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||
let surface_format = surface_caps
|
||||
.formats
|
||||
.iter()
|
||||
@@ -188,20 +243,6 @@ impl CanvasState {
|
||||
.find(|f| f.is_srgb())
|
||||
.unwrap_or(surface_caps.formats[0]);
|
||||
|
||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
||||
wgpu::PresentMode::Fifo
|
||||
} else if surface_caps
|
||||
.present_modes
|
||||
.contains(&wgpu::PresentMode::Mailbox)
|
||||
{
|
||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
|
||||
wgpu::PresentMode::Fifo
|
||||
};
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_format,
|
||||
@@ -240,6 +281,7 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [0.0; 4],
|
||||
};
|
||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Camera Uniform Buffer"),
|
||||
@@ -276,7 +318,7 @@ impl CanvasState {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
entry_point: "fs_ink",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
@@ -330,7 +372,8 @@ impl CanvasState {
|
||||
});
|
||||
|
||||
// 6. Statische Buffer
|
||||
let paper_quad = generate_paper_quad();
|
||||
let paper_theme = PaperTheme::default();
|
||||
let paper_quad = generate_paper_quad(paper_theme.paper_color());
|
||||
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Paper Quad Buffer"),
|
||||
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -340,7 +383,7 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(current_background);
|
||||
let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
|
||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer"),
|
||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -369,6 +412,8 @@ impl CanvasState {
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
adapter,
|
||||
presentation_warned: false,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
@@ -382,12 +427,13 @@ impl CanvasState {
|
||||
camera_bind_group,
|
||||
current_ink_color: default_ink_color,
|
||||
current_stroke_width_mm: 0.5,
|
||||
current_tool: crate::document::StrokeTool::Pen,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
live_vertex_buffer,
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
@@ -415,6 +461,16 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
if self.adapt_ink && self.paper_theme.is_dark() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
@@ -452,6 +508,23 @@ impl CanvasState {
|
||||
self.size = new_size;
|
||||
self.config.width = new_size.width;
|
||||
self.config.height = new_size.height;
|
||||
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||
let caps = self.surface.get_capabilities(&self.adapter);
|
||||
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||
match choose_present_mode(&caps.present_modes) {
|
||||
Some(mode) => self.config.present_mode = mode,
|
||||
None => {
|
||||
if !self.presentation_warned {
|
||||
eprintln!(
|
||||
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
||||
NO_USABLE_GPU_MESSAGE
|
||||
);
|
||||
self.presentation_warned = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
@@ -532,9 +605,19 @@ impl CanvasState {
|
||||
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||
}
|
||||
|
||||
/// Setzt das aktive Zeichenwerkzeug für den Stift.
|
||||
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
|
||||
self.current_tool = tool;
|
||||
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
|
||||
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
|
||||
self.adapt_ink = adapt_ink;
|
||||
if self.paper_theme != theme {
|
||||
self.paper_theme = theme;
|
||||
self.queue.write_buffer(
|
||||
&self.paper_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
|
||||
);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||
@@ -543,7 +626,13 @@ impl CanvasState {
|
||||
return;
|
||||
}
|
||||
self.current_background = background;
|
||||
let mesh = generate_background_mesh(background);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
|
||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||
fn upload_background_mesh(&mut self) {
|
||||
let mesh =
|
||||
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
@@ -673,7 +762,6 @@ impl CanvasState {
|
||||
&self.spline_config,
|
||||
);
|
||||
let mut stroke = stroke;
|
||||
stroke.tool = self.current_tool;
|
||||
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||
stroke.finalized = true;
|
||||
|
||||
@@ -769,7 +857,7 @@ impl CanvasState {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund
|
||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
@@ -781,7 +869,7 @@ impl CanvasState {
|
||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
|
||||
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
// 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
@@ -858,6 +946,16 @@ impl CanvasState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_present_mode() {
|
||||
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
|
||||
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
|
||||
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
|
||||
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
|
||||
assert_eq!(choose_present_mode(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_to_ndc_mapping() {
|
||||
let width = 800.0;
|
||||
@@ -883,7 +981,40 @@ mod tests {
|
||||
#[test]
|
||||
fn test_camera_uniforms() {
|
||||
let _u = CameraUniforms::default();
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
|
||||
// Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shader_is_valid_and_matches_uniforms() {
|
||||
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
|
||||
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
|
||||
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
||||
|
||||
for name in ["vs_main", "fs_main", "fs_ink"] {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|ep| ep.name == name),
|
||||
"Einstiegspunkt {name} fehlt"
|
||||
);
|
||||
}
|
||||
let camera = module
|
||||
.types
|
||||
.iter()
|
||||
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
|
||||
.expect("CameraUniforms fehlt im Shader")
|
||||
.1;
|
||||
match camera.inner {
|
||||
naga::TypeInner::Struct { span, .. } => {
|
||||
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
|
||||
}
|
||||
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+92
-3
@@ -113,6 +113,39 @@ pub enum StrokeTool {
|
||||
Shape,
|
||||
}
|
||||
|
||||
impl StrokeTool {
|
||||
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
|
||||
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
|
||||
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
|
||||
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
|
||||
match stored {
|
||||
StrokeTool::Pen if has_shape => StrokeTool::Shape,
|
||||
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Striche der Radierer entfernt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EraserMode {
|
||||
/// Alle getroffenen Striche
|
||||
#[default]
|
||||
All,
|
||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||
HighlighterOnly,
|
||||
}
|
||||
|
||||
impl EraserMode {
|
||||
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
||||
pub fn erases(self, tool: StrokeTool) -> bool {
|
||||
match self {
|
||||
EraserMode::All => true,
|
||||
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
@@ -469,7 +502,8 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
|
||||
/// die `mode` zulässt.
|
||||
///
|
||||
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
||||
pub fn erase_at(
|
||||
@@ -477,11 +511,14 @@ impl Page {
|
||||
eraser_pos: glam::Vec2,
|
||||
eraser_radius: f32,
|
||||
max_stroke_radius: f32,
|
||||
mode: EraserMode,
|
||||
) -> Vec<Stroke> {
|
||||
let mut hit_indices = Vec::new();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) {
|
||||
if mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
{
|
||||
hit_indices.push(i);
|
||||
}
|
||||
}
|
||||
@@ -1040,7 +1077,7 @@ mod tests {
|
||||
assert_eq!(page.strokes.len(), 2);
|
||||
|
||||
// Radieren bei (30, 12): Trifft nur s1
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0);
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 1);
|
||||
assert_eq!(removed[0].id, 1);
|
||||
assert_eq!(page.strokes.len(), 1);
|
||||
@@ -1056,6 +1093,58 @@ mod tests {
|
||||
assert_eq!(page.strokes[0].id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_erase_highlighter_only_keeps_handwriting() {
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
let line = |id: u64, tool: StrokeTool| {
|
||||
let mut stroke = Stroke::new(
|
||||
id,
|
||||
vec![
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(10.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(50.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
stroke.tool = tool;
|
||||
stroke
|
||||
};
|
||||
// Handschrift, Form und Textmarker liegen übereinander
|
||||
page.add_stroke(line(1, StrokeTool::Pen));
|
||||
page.add_stroke(line(2, StrokeTool::Highlighter));
|
||||
page.add_stroke(line(3, StrokeTool::Shape));
|
||||
|
||||
let at = glam::Vec2::new(30.0, 10.0);
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
|
||||
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert_eq!(
|
||||
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
|
||||
assert!(page
|
||||
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
|
||||
.is_empty());
|
||||
assert!(page.undo());
|
||||
assert_eq!(page.strokes.len(), 3);
|
||||
|
||||
// Normaler Radierer entfernt alles
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 3);
|
||||
assert!(page.strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_isolation() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+121
-65
@@ -329,6 +329,59 @@ fn render_recognized_shape(
|
||||
layer.add_polygon(poly);
|
||||
}
|
||||
|
||||
RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => {
|
||||
let ring = (0..72)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
let p =
|
||||
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Stroke,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
|
||||
RecognizedShape::Arrow { start, end, double } => {
|
||||
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let shaft_len = (*end - *start).length();
|
||||
let dir = (*end - *start).normalize_or_zero();
|
||||
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let start_head =
|
||||
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let line_start = if *double { start_head.0 } else { *start };
|
||||
let to_pdf = |p: glam::Vec2| {
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
};
|
||||
layer.add_line(Line {
|
||||
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
|
||||
is_closed: false,
|
||||
});
|
||||
layer.set_fill_color(stroke_color);
|
||||
let mut heads = vec![(*end, end_head)];
|
||||
if *double {
|
||||
heads.push((*start, start_head));
|
||||
}
|
||||
for (tip, (_, left, right)) in heads {
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RecognizedShape::Rectangle { corners } => {
|
||||
let mut ring = Vec::with_capacity(4);
|
||||
for c in corners {
|
||||
@@ -412,76 +465,58 @@ fn render_recognized_shape(
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
} => {
|
||||
// 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)
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let y_arrow = crate::geometry::arrow_head(
|
||||
*y_axis_end,
|
||||
y_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*y_axis_end - *origin).length(),
|
||||
);
|
||||
let x_arrow = crate::geometry::arrow_head(
|
||||
*x_axis_end,
|
||||
x_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*x_axis_end - *origin).length(),
|
||||
);
|
||||
let (y_line_end, x_line_end) = if *with_arrows {
|
||||
(y_arrow.0, x_arrow.0)
|
||||
} else {
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
|
||||
// Achse 1 (Y)
|
||||
let line_y = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(yx), Mm(yy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_y);
|
||||
|
||||
// Achse 2 (X)
|
||||
let line_x = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(xx), Mm(xy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_x);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
layer.set_fill_color(stroke_color);
|
||||
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let arrow_len_page = 25.0;
|
||||
let arrow_w_page = 16.0;
|
||||
|
||||
let base_y = *y_axis_end - y_dir * arrow_len_page;
|
||||
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
|
||||
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
|
||||
|
||||
let arrow_poly_y = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx), Mm(ty)), false),
|
||||
(Point::new(Mm(lx), Mm(ly)), false),
|
||||
(Point::new(Mm(rx), Mm(ry)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_y);
|
||||
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let base_x = *x_axis_end - x_dir * arrow_len_page;
|
||||
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
|
||||
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
|
||||
|
||||
let arrow_poly_x = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx_x), Mm(ty_x)), false),
|
||||
(Point::new(Mm(lx_x), Mm(ly_x)), false),
|
||||
(Point::new(Mm(rx_x), Mm(ry_x)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_x);
|
||||
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
|
||||
let ring = [tip, left, right]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -838,6 +873,27 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(coords_stroke);
|
||||
|
||||
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Ellipse {
|
||||
center: glam::Vec2::new(1400.0, 600.0),
|
||||
radius_x: 300.0,
|
||||
radius_y: 150.0,
|
||||
rotation: 0.4,
|
||||
},
|
||||
);
|
||||
page.add_stroke(ellipse_stroke);
|
||||
|
||||
for (id, double) in [(41, false), (42, true)] {
|
||||
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Arrow {
|
||||
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
|
||||
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
|
||||
double,
|
||||
},
|
||||
);
|
||||
page.add_stroke(arrow_stroke);
|
||||
}
|
||||
|
||||
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Triangle {
|
||||
corners: [
|
||||
|
||||
+1373
-145
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ pub mod export;
|
||||
pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod platform;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
|
||||
+88
-46
@@ -9,8 +9,9 @@ use winit::{
|
||||
event::{
|
||||
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
||||
},
|
||||
event_loop::{ControlFlow, EventLoop},
|
||||
event_loop::{ControlFlow, EventLoopBuilder},
|
||||
keyboard::{Key, ModifiersState, NamedKey},
|
||||
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
||||
window::{Window, WindowBuilder},
|
||||
};
|
||||
|
||||
@@ -20,6 +21,7 @@ use ink_notes::canvas::CanvasState;
|
||||
use ink_notes::document::{Notebook, PageBackground};
|
||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||
use ink_notes::geometry::{self, RecognizedShape};
|
||||
use ink_notes::platform::{self, DisplayBackend};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
@@ -29,6 +31,19 @@ use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
||||
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
|
||||
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
|
||||
if let Some(path) = path {
|
||||
if let Err(err) = settings.save_to(path) {
|
||||
eprintln!(
|
||||
"[Einstellungen] Konnte '{}' nicht speichern: {err}",
|
||||
path.display()
|
||||
);
|
||||
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_window_title(
|
||||
window: &Window,
|
||||
notebook: &Notebook,
|
||||
@@ -55,21 +70,6 @@ fn update_window_title(
|
||||
window.set_title(&title);
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt.
|
||||
///
|
||||
/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen
|
||||
/// Pixeln. `layer_id_at` erfasst jedes egui-Element des letzten Frames (Leiste, Popover,
|
||||
/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch).
|
||||
fn is_screen_point_in_ui(
|
||||
egui_ctx: &egui::Context,
|
||||
ui_state: &UiState,
|
||||
screen_pos: (f32, f32),
|
||||
scale: f32,
|
||||
) -> bool {
|
||||
let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale);
|
||||
ui_state.is_point_in_ui(pos.x, pos.y) || egui_ctx.layer_id_at(pos).is_some()
|
||||
}
|
||||
|
||||
/// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher
|
||||
/// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück.
|
||||
fn start_pdf_export(
|
||||
@@ -134,7 +134,8 @@ fn commit_stroke_to_notebook(
|
||||
canvas.finish_stroke(new_id)
|
||||
};
|
||||
|
||||
if let Some(stroke) = stroke_opt {
|
||||
if let Some(mut stroke) = stroke_opt {
|
||||
stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some());
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
canvas.sync_page(page);
|
||||
@@ -152,7 +153,20 @@ fn commit_stroke_to_notebook(
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
|
||||
let mut event_loop_builder = EventLoopBuilder::new();
|
||||
match platform::display_backend_from_env() {
|
||||
DisplayBackend::X11 => {
|
||||
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_x11();
|
||||
}
|
||||
DisplayBackend::Wayland => {
|
||||
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||
event_loop_builder.with_wayland();
|
||||
}
|
||||
DisplayBackend::Automatic => {}
|
||||
}
|
||||
let event_loop = event_loop_builder.build()?;
|
||||
let window = Arc::new(
|
||||
WindowBuilder::new()
|
||||
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
|
||||
@@ -160,7 +174,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.build(&event_loop)?,
|
||||
);
|
||||
|
||||
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?;
|
||||
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
|
||||
Ok(canvas) => canvas,
|
||||
Err(err) => {
|
||||
eprintln!("[Grafik-Fehler] {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
|
||||
Ok(mgr) => {
|
||||
@@ -253,6 +273,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(UiSettings::load_from)
|
||||
.unwrap_or_default();
|
||||
ui_state.sidebar_side = ui_settings.sidebar_side;
|
||||
ui_state.paper_theme = ui_settings.paper_theme;
|
||||
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
|
||||
ui_state.pressure_curve = ui_settings.pressure_curve;
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
@@ -270,7 +293,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||
eprintln!("[Storage-Lock] {msg}");
|
||||
autosave.set_read_only(true);
|
||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -284,6 +307,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
canvas.set_stroke_color(ui_state.pen_color);
|
||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
||||
@@ -465,7 +489,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cursor_screen_pos = (sx, sy);
|
||||
|
||||
if let Some(p) = pose.pressure.get() {
|
||||
current_pressure = p;
|
||||
current_pressure = ui_state.pressure_curve.apply(p);
|
||||
ui_state.last_raw_pressure = Some(p);
|
||||
}
|
||||
|
||||
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||
@@ -477,7 +502,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.events
|
||||
.push(egui::Event::PointerMoved(ui_pos));
|
||||
if stylus_ui_capture
|
||||
|| is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||
{
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -492,8 +517,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius,
|
||||
);
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -532,7 +556,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
stylus_tool_id = Some(tool.id());
|
||||
let (sx, sy) = stylus_screen_pos;
|
||||
|
||||
if is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||
@@ -560,8 +584,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(
|
||||
page_pt,
|
||||
eraser_radius,
|
||||
stroke_max_radius,
|
||||
);
|
||||
stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -710,7 +733,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
||||
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
||||
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
||||
|| is_screen_point_in_ui(
|
||||
|| ui::is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
&ui_state,
|
||||
cursor_screen_pos,
|
||||
@@ -1181,7 +1204,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1195,7 +1218,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 2. Objektauswahl & Transformation im Select-Modus
|
||||
if ui_state.active_tool == ActiveTool::Select {
|
||||
let screen_vec = Vec2::new(sx, sy);
|
||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||
match hit {
|
||||
SelectionHandleHit::RotateHandle { center_page } => {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
@@ -1449,7 +1472,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else if is_erasing {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1510,15 +1533,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let now = start_time.elapsed().as_secs_f64();
|
||||
let tx = touch.location.x as f32;
|
||||
let ty = touch.location.y as f32;
|
||||
let pressure = match touch.force {
|
||||
Some(winit::event::Force::Normalized(f)) => f as f32,
|
||||
let raw_pressure = match touch.force {
|
||||
Some(winit::event::Force::Normalized(f)) => Some(f as f32),
|
||||
Some(winit::event::Force::Calibrated {
|
||||
force,
|
||||
max_possible_force,
|
||||
..
|
||||
}) => (force / max_possible_force).clamp(0.0, 1.0) as f32,
|
||||
None => 0.5,
|
||||
}) => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
|
||||
None => None,
|
||||
};
|
||||
// Druckkurve nur bei echtem Druck; Finger ohne Druck zeichnen mittelstark
|
||||
let pressure =
|
||||
raw_pressure.map_or(0.5, |p| ui_state.pressure_curve.apply(p));
|
||||
|
||||
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
@@ -1539,7 +1565,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match touch.phase {
|
||||
TouchPhase::Started => {
|
||||
// Event-Isolation für Touch-Eingaben
|
||||
let is_touch_in_ui = is_screen_point_in_ui(
|
||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
&ui_state,
|
||||
(tx, ty),
|
||||
@@ -1585,7 +1611,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_erasing = true;
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1595,7 +1621,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
} else if ui_state.active_tool == ActiveTool::Select {
|
||||
let screen_vec = Vec2::new(tx, ty);
|
||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||
match hit {
|
||||
SelectionHandleHit::RotateHandle { center_page } => {
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
@@ -1726,7 +1752,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else if is_erasing {
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
let page = notebook.current_page_mut();
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -2083,6 +2109,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::LoadAllPages => {
|
||||
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
|
||||
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
|
||||
for idx in 0..notebook.pages.len() {
|
||||
if !notebook.pages[idx].is_loaded {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::GoToPage(idx) => {
|
||||
if idx < notebook.pages.len() {
|
||||
selection_state.clear();
|
||||
@@ -2133,7 +2169,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("[Storage-Lock] {msg}");
|
||||
_notebook_lock = None;
|
||||
autosave.set_read_only(true);
|
||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||
@@ -2394,14 +2430,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
UiAction::SetSidebarSide(side) => {
|
||||
ui_settings.sidebar_side = side;
|
||||
if let Some(path) = ui_settings_path.as_deref() {
|
||||
if let Err(err) = ui_settings.save_to(path) {
|
||||
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||
}
|
||||
}
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetPaperTheme { theme, adapt_ink } => {
|
||||
canvas.set_paper_theme(theme, adapt_ink);
|
||||
ui_settings.paper_theme = theme;
|
||||
ui_settings.adapt_ink_colors = adapt_ink;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SavePressureCurve(curve) => {
|
||||
ui_settings.pressure_curve = curve;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
}
|
||||
UiAction::ForceExit => {
|
||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||
target.exit();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||
//!
|
||||
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||
//! übersteuert die Wahl.
|
||||
|
||||
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayBackend {
|
||||
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||
Automatic,
|
||||
X11,
|
||||
Wayland,
|
||||
}
|
||||
|
||||
/// Wählt das Anzeigesystem.
|
||||
///
|
||||
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||
/// - `is_wsl`: läuft unter WSL
|
||||
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||
pub fn choose_display_backend(
|
||||
override_value: Option<&str>,
|
||||
is_wsl: bool,
|
||||
has_x11_display: bool,
|
||||
) -> DisplayBackend {
|
||||
match override_value
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("x11") => return DisplayBackend::X11,
|
||||
Some("wayland") => return DisplayBackend::Wayland,
|
||||
_ => {}
|
||||
}
|
||||
if is_wsl && has_x11_display {
|
||||
DisplayBackend::X11
|
||||
} else {
|
||||
DisplayBackend::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||
pub fn is_wsl() -> bool {
|
||||
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Wahl anhand der aktuellen Umgebung.
|
||||
pub fn display_backend_from_env() -> DisplayBackend {
|
||||
choose_display_backend(
|
||||
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||
is_wsl(),
|
||||
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
// WSL ohne X11-Display: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, false),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Natives Linux: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, false, true),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Übersteuerung
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("wayland"), true, true),
|
||||
DisplayBackend::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some(" X11 "), false, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("quatsch"), true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-12
@@ -115,12 +115,15 @@ impl SelectionState {
|
||||
Page::selection_aabb(&strokes)
|
||||
}
|
||||
|
||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||
pub fn hit_test_handles(
|
||||
&self,
|
||||
page: &Page,
|
||||
canvas: &CanvasState,
|
||||
screen_pos: Vec2,
|
||||
pixels_per_point: f32,
|
||||
) -> SelectionHandleHit {
|
||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||
return SelectionHandleHit::None;
|
||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
||||
let screen_center = (screen_min + screen_max) * 0.5;
|
||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||
|
||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
||||
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||
let ppp = pixels_per_point;
|
||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||
return SelectionHandleHit::RotateHandle {
|
||||
center_page: page_center,
|
||||
};
|
||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
||||
];
|
||||
|
||||
for i in 0..4 {
|
||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
||||
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||
return SelectionHandleHit::CornerHandle {
|
||||
corner_idx: i,
|
||||
fixed_corner_page: fixed_corners_page[i],
|
||||
@@ -273,6 +277,16 @@ impl SelectionState {
|
||||
}
|
||||
|
||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||
|
||||
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||
}
|
||||
|
||||
pub fn render_selection_overlay(
|
||||
ctx: &egui::Context,
|
||||
selection: &SelectionState,
|
||||
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
|
||||
egui::Order::Foreground,
|
||||
egui::Id::new("selection_overlay_layer"),
|
||||
));
|
||||
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||
let ppp = ctx.pixels_per_point();
|
||||
|
||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||
if let DragMode::BoxSelect {
|
||||
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
|
||||
current_page,
|
||||
} = selection.drag_mode
|
||||
{
|
||||
let p1 = canvas.page_to_screen(start_page);
|
||||
let p2 = canvas.page_to_screen(current_page);
|
||||
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||
let min_x = p1[0].min(p2[0]);
|
||||
let min_y = p1[1].min(p2[1]);
|
||||
let max_x = p1[0].max(p2[0]);
|
||||
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::Rounding::same(4.0_f32),
|
||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
||||
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::Rounding::same(4.0_f32),
|
||||
egui::Stroke::new(
|
||||
1.5_f32,
|
||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
||||
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
|
||||
return;
|
||||
};
|
||||
|
||||
let p_min = canvas.page_to_screen(aabb.min);
|
||||
let p_max = canvas.page_to_screen(aabb.max);
|
||||
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||
|
||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
|
||||
|
||||
// Dreh-Stängel (Stalk) und Drehgriff
|
||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||
|
||||
painter.line_segment(
|
||||
[top_center, rot_handle],
|
||||
|
||||
+149
-3
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::background::PaperTheme;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||
@@ -18,11 +19,82 @@ pub enum SidebarSide {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
|
||||
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PressureCurve {
|
||||
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
|
||||
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
|
||||
pub exponent: f32,
|
||||
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
|
||||
/// unterdrückt das Grundrauschen empfindlicher Stifte
|
||||
pub threshold: f32,
|
||||
}
|
||||
|
||||
impl PressureCurve {
|
||||
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
|
||||
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
|
||||
|
||||
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
|
||||
pub fn apply(&self, raw: f32) -> f32 {
|
||||
let curve = self.sanitized();
|
||||
if !raw.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
|
||||
normalized.powf(curve.exponent)
|
||||
}
|
||||
|
||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
|
||||
/// gültigen Bereich.
|
||||
pub fn sanitized(&self) -> Self {
|
||||
let default = Self::default();
|
||||
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
|
||||
if v.is_finite() {
|
||||
v.clamp(*range.start(), *range.end())
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
};
|
||||
Self {
|
||||
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
|
||||
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PressureCurve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
exponent: 1.0,
|
||||
threshold: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiSettings {
|
||||
pub sidebar_side: SidebarSide,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar_side: SidebarSide::default(),
|
||||
paper_theme: PaperTheme::default(),
|
||||
adapt_ink_colors: true,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiSettings {
|
||||
@@ -37,7 +109,7 @@ impl UiSettings {
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
let mut settings: Self = match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||
@@ -46,7 +118,9 @@ impl UiSettings {
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Speichert die Einstellungen atomar.
|
||||
@@ -69,6 +143,12 @@ mod tests {
|
||||
|
||||
let settings = UiSettings {
|
||||
sidebar_side: SidebarSide::Right,
|
||||
paper_theme: PaperTheme::Black,
|
||||
adapt_ink_colors: false,
|
||||
pressure_curve: PressureCurve {
|
||||
exponent: 1.8,
|
||||
threshold: 0.1,
|
||||
},
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -81,4 +161,70 @@ mod tests {
|
||||
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_file_keeps_new_defaults() {
|
||||
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ui.json");
|
||||
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.sidebar_side, SidebarSide::Right);
|
||||
assert_eq!(settings.paper_theme, PaperTheme::White);
|
||||
assert!(settings.adapt_ink_colors);
|
||||
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||
|
||||
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||
assert_eq!(curve.exponent, 3.0);
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_curve() {
|
||||
// Standard: unverändert
|
||||
let linear = PressureCurve::default();
|
||||
for p in [0.0, 0.25, 0.5, 1.0] {
|
||||
assert!((linear.apply(p) - p).abs() < 1e-6);
|
||||
}
|
||||
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
|
||||
let soft = PressureCurve {
|
||||
exponent: 0.5,
|
||||
threshold: 0.0,
|
||||
};
|
||||
let hard = PressureCurve {
|
||||
exponent: 2.0,
|
||||
threshold: 0.0,
|
||||
};
|
||||
assert!(soft.apply(0.5) > 0.6);
|
||||
assert!(hard.apply(0.5) < 0.3);
|
||||
// Endpunkte bleiben fest
|
||||
for curve in [soft, hard] {
|
||||
assert_eq!(curve.apply(0.0), 0.0);
|
||||
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
|
||||
let gated = PressureCurve {
|
||||
exponent: 1.0,
|
||||
threshold: 0.2,
|
||||
};
|
||||
assert_eq!(gated.apply(0.15), 0.0);
|
||||
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
|
||||
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
// Monoton steigend
|
||||
let mut last = -1.0;
|
||||
for i in 0..=100 {
|
||||
let v = hard.apply(i as f32 / 100.0);
|
||||
assert!(v >= last);
|
||||
last = v;
|
||||
}
|
||||
// Ungültige Eingaben ergeben nie NaN
|
||||
assert_eq!(linear.apply(f32::NAN), 0.0);
|
||||
assert_eq!(linear.apply(7.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ struct VertexOutput {
|
||||
struct CameraUniforms {
|
||||
transform_scale: vec2<f32>,
|
||||
transform_offset: vec2<f32>,
|
||||
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
|
||||
ink_params: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0)
|
||||
@@ -32,3 +34,16 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
}
|
||||
|
||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
@fragment
|
||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if (camera.ink_params.x < 0.5) {
|
||||
return in.color;
|
||||
}
|
||||
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
||||
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
||||
}
|
||||
|
||||
+152
-3
@@ -117,15 +117,23 @@ impl StrokeData {
|
||||
};
|
||||
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
|
||||
stroke.shape = self.shape.clone();
|
||||
stroke.tool = self.tool;
|
||||
stroke.tool = crate::document::StrokeTool::resolve_stored(
|
||||
self.tool,
|
||||
self.color,
|
||||
self.shape.is_some(),
|
||||
);
|
||||
stroke.width = width;
|
||||
stroke.finalized = self.finalized;
|
||||
stroke
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 2;
|
||||
/// Aktuelle Dateiformat-Version von ink-notes.
|
||||
/// - Version 2: Stroke-Treue mit tool, width, finalized
|
||||
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
|
||||
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
|
||||
/// sauber als „neuere Version“ ab.
|
||||
pub const CURRENT_FORMAT_VERSION: u32 = 3;
|
||||
|
||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||
pub fn legacy_format_version() -> u32 {
|
||||
@@ -697,6 +705,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
||||
);
|
||||
notebook.load_warnings = load_warnings;
|
||||
|
||||
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
|
||||
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
|
||||
let current = notebook.current_page_index;
|
||||
if !notebook.pages[current].is_loaded {
|
||||
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
|
||||
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok((notebook, manifest))
|
||||
}
|
||||
|
||||
@@ -2167,4 +2184,136 @@ mod tests {
|
||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_on_later_page_keeps_its_content() {
|
||||
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
|
||||
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||
notebook.current_page_index = 1;
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
|
||||
assert_eq!(loaded.current_page_index, 1);
|
||||
assert!(
|
||||
loaded.current_page().is_loaded,
|
||||
"aktuelle Seite muss geladen sein"
|
||||
);
|
||||
assert_eq!(loaded.current_page().strokes.len(), 1);
|
||||
|
||||
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
|
||||
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
|
||||
loaded.current_page_mut().strokes.push(extra);
|
||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||
autosave.mark_dirty(&loaded, &mut loaded_manifest);
|
||||
autosave.flush().unwrap();
|
||||
autosave.shutdown().unwrap();
|
||||
|
||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||
assert_eq!(reloaded.current_page().strokes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autosave_never_writes_unloaded_placeholder() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
|
||||
let before = fs::read(&page_file).unwrap();
|
||||
|
||||
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
|
||||
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
|
||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||
autosave.mark_page_dirty(&placeholder, &manifest);
|
||||
autosave.flush().unwrap();
|
||||
autosave.shutdown().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read(&page_file).unwrap(),
|
||||
before,
|
||||
"Seitendatei wurde überschrieben"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_shapes_round_trip_through_page_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest, dir) =
|
||||
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
|
||||
let shapes = [
|
||||
crate::geometry::RecognizedShape::Ellipse {
|
||||
center: glam::Vec2::new(500.0, 600.0),
|
||||
radius_x: 250.0,
|
||||
radius_y: 120.0,
|
||||
rotation: 0.3,
|
||||
},
|
||||
crate::geometry::RecognizedShape::Arrow {
|
||||
start: glam::Vec2::new(100.0, 100.0),
|
||||
end: glam::Vec2::new(700.0, 300.0),
|
||||
double: true,
|
||||
},
|
||||
];
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
notebook
|
||||
.current_page_mut()
|
||||
.strokes
|
||||
.push(crate::geometry::shape_to_stroke(
|
||||
shape.clone(),
|
||||
i as u64 + 1,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
5.0,
|
||||
));
|
||||
}
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
assert_eq!(
|
||||
load_manifest(&dir).unwrap().format_version,
|
||||
CURRENT_FORMAT_VERSION
|
||||
);
|
||||
|
||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||
let loaded: Vec<_> = reloaded
|
||||
.current_page()
|
||||
.strokes
|
||||
.iter()
|
||||
.map(|s| s.shape.clone())
|
||||
.collect();
|
||||
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stroke_tool_of_old_files_is_recovered() {
|
||||
// Bis v0.3.7 wurde jeder Strich als "pen" gespeichert
|
||||
let json = r#"{"id": 1, "color": [1.0, 0.95, 0.1, 0.35], "tool": "pen",
|
||||
"samples": [{"x": 10.0, "y": 10.0, "p": 0.5, "t": 0.0},
|
||||
{"x": 90.0, "y": 10.0, "p": 0.5, "t": 0.1}]}"#;
|
||||
let data: StrokeData = serde_json::from_str(json).unwrap();
|
||||
let highlighter = data.to_stroke();
|
||||
assert_eq!(highlighter.tool, crate::document::StrokeTool::Highlighter);
|
||||
|
||||
let mut pen = data.clone();
|
||||
pen.color = [0.12, 0.12, 0.14, 1.0];
|
||||
assert_eq!(pen.to_stroke().tool, crate::document::StrokeTool::Pen);
|
||||
|
||||
let mut shape = pen.clone();
|
||||
shape.shape = Some(crate::geometry::RecognizedShape::Line {
|
||||
start: glam::Vec2::new(10.0, 10.0),
|
||||
end: glam::Vec2::new(90.0, 10.0),
|
||||
});
|
||||
assert_eq!(shape.to_stroke().tool, crate::document::StrokeTool::Shape);
|
||||
|
||||
// Ausdrücklich gespeicherte Werkzeuge bleiben unverändert
|
||||
let mut explicit = data.clone();
|
||||
explicit.tool = crate::document::StrokeTool::Shape;
|
||||
assert_eq!(
|
||||
explicit.to_stroke().tool,
|
||||
crate::document::StrokeTool::Shape
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user