Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5115e217c2 | ||
|
|
904b36e5f7 | ||
|
|
80d319099f | ||
|
|
ee5257d744 | ||
|
|
fa1ab5ab8f | ||
|
|
47300f2ca2 | ||
|
|
27f84ff1b1 | ||
|
|
ffab2cea7a |
No files matched your search
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.11.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.11.0"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# ink-notes
|
||||
<p align="center">
|
||||
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
|
||||
</p>
|
||||
|
||||
Eine Notizbuch-App für Linux zum Schreiben mit Stift, Finger oder Maus – geschrieben in **Rust** mit **wgpu**, **winit** und **egui**.
|
||||
<p align="center">
|
||||
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
|
||||
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
|
||||
<defs>
|
||||
<!-- Haupt-Hintergrund-Verlauf -->
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f1420"/>
|
||||
<stop offset="40%" stop-color="#141a29"/>
|
||||
<stop offset="100%" stop-color="#090b12"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Subtiles Ambient Glow -->
|
||||
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
|
||||
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Icon Hintergrund-Verlauf -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e2638"/>
|
||||
<stop offset="50%" stop-color="#141824"/>
|
||||
<stop offset="100%" stop-color="#0c0e17"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Stiftkörper-Verlauf -->
|
||||
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Goldene Feder-Spitze -->
|
||||
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fbbf24"/>
|
||||
<stop offset="50%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#d97706"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Tintenspur-Verlauf -->
|
||||
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
|
||||
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Text Ink Gradient -->
|
||||
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#38bdf8"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Leucht-Effekt -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
|
||||
<!-- Gitter-Muster für Hintergrund -->
|
||||
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Banner-Rahmen & Hintergrund -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
|
||||
|
||||
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
|
||||
|
||||
<!-- Subtile Glows -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
|
||||
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
|
||||
|
||||
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
|
||||
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
|
||||
<!-- App-Icon Hintergrund (Squircle) -->
|
||||
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
|
||||
|
||||
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
|
||||
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
|
||||
<line x1="80" y1="160" x2="432" y2="160" />
|
||||
<line x1="80" y1="220" x2="432" y2="220" />
|
||||
<line x1="80" y1="280" x2="432" y2="280" />
|
||||
<line x1="80" y1="340" x2="432" y2="340" />
|
||||
<line x1="80" y1="400" x2="432" y2="400" />
|
||||
|
||||
<line x1="140" y1="100" x2="140" y2="430" />
|
||||
<line x1="200" y1="100" x2="200" y2="430" />
|
||||
<line x1="260" y1="100" x2="260" y2="430" />
|
||||
<line x1="320" y1="100" x2="320" y2="430" />
|
||||
<line x1="380" y1="100" x2="380" y2="430" />
|
||||
</g>
|
||||
|
||||
<!-- Tintenspur -->
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
|
||||
|
||||
<!-- Stylus / Füllfederhalter -->
|
||||
<g transform="translate(240, 110) rotate(45)">
|
||||
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
|
||||
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
|
||||
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
|
||||
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="0" cy="65" r="4" fill="#78350f"/>
|
||||
</g>
|
||||
|
||||
<!-- Glanzpunkt -->
|
||||
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
|
||||
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
|
||||
</g>
|
||||
|
||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||
<g transform="translate(360, 0)">
|
||||
<!-- Version & Platform Pill -->
|
||||
<g transform="translate(0, 68)">
|
||||
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
||||
v0.14.0 • LINUX • RUST & WGPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Haupttitel: ink-notes -->
|
||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle / Tagline -->
|
||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
||||
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
||||
</text>
|
||||
|
||||
<!-- Feature Pills / Highlights -->
|
||||
<g transform="translate(0, 248)">
|
||||
<!-- Pill 1: Niedrige Latenz -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
⚡ Schnelles wgpu-Inking
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 2: Stylus mit Druck -->
|
||||
<g transform="translate(198, 0)">
|
||||
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🖊️ Druck & Palm Rejection
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 3: Formerkennung -->
|
||||
<g transform="translate(400, 0)">
|
||||
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
📐 Formenerkennung & Lineal
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 4: 100% lokal -->
|
||||
<g transform="translate(594, 0)">
|
||||
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% lokal & atomar
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -3,6 +3,9 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
|
||||
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
|
||||
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
||||
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
||||
|
||||
+74
-126
@@ -165,115 +165,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
|
||||
]
|
||||
}
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
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));
|
||||
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
|
||||
pub const LINED_MARGIN: f32 = 150.0;
|
||||
|
||||
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
|
||||
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
|
||||
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
|
||||
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PatternVertex {
|
||||
/// Seitenkoordinate
|
||||
pub position: [f32; 2],
|
||||
pub color: [f32; 4],
|
||||
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
|
||||
pub params: [f32; 4],
|
||||
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
|
||||
pub bounds: [f32; 4],
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
||||
/// einem Blatt der Größe `size`.
|
||||
pub fn generate_background_mesh(
|
||||
impl PatternVertex {
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
|
||||
0 => Float32x2,
|
||||
1 => Float32x4,
|
||||
2 => Float32x4,
|
||||
3 => Float32x4
|
||||
];
|
||||
|
||||
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &Self::ATTRIBUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
|
||||
/// `background` in `color` zeichnet; leer bei blankem Papier.
|
||||
pub fn generate_pattern_quad(
|
||||
background: PageBackground,
|
||||
color: [f32; 4],
|
||||
size: glam::Vec2,
|
||||
) -> Vec<InkVertex> {
|
||||
let (page_width, page_height) = (size.x, size.y);
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
let mut vertices = Vec::with_capacity(1200);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
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,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::Lined => {
|
||||
let mut vertices = Vec::with_capacity(600);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Oberer und unterer Randabstand (z. B. 15 mm)
|
||||
let margin_top = 150.0;
|
||||
let margin_bottom = 150.0;
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= page_height - margin_bottom {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::DotGrid => {
|
||||
let mut vertices = Vec::with_capacity(15000);
|
||||
let half_d = DOT_SIZE / 2.0;
|
||||
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < page_height {
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
y + half_d,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
}
|
||||
) -> Vec<PatternVertex> {
|
||||
let params = match background {
|
||||
PageBackground::Blank => return Vec::new(),
|
||||
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
};
|
||||
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
|
||||
let corner = |x: f32, y: f32| PatternVertex {
|
||||
position: [x, y],
|
||||
color,
|
||||
params,
|
||||
bounds,
|
||||
};
|
||||
vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(size.x, 0.0),
|
||||
corner(0.0, size.y),
|
||||
corner(0.0, size.y),
|
||||
corner(size.x, 0.0),
|
||||
corner(size.x, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -323,32 +278,25 @@ mod tests {
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
0,
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
fn test_pattern_quads() {
|
||||
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
|
||||
for (background, kind) in [
|
||||
(PageBackground::Grid5mm, 1.0),
|
||||
(PageBackground::Lined, 2.0),
|
||||
(PageBackground::DotGrid, 3.0),
|
||||
] {
|
||||
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.params[0] == kind && v.color == PATTERN));
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||
assert!(quad
|
||||
.iter()
|
||||
|
||||
+171
-36
@@ -12,7 +12,9 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor};
|
||||
use crate::background::{
|
||||
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
|
||||
};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -150,8 +152,9 @@ struct NeighborPage {
|
||||
offset: glam::Vec2,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
background_buffer: Option<wgpu::Buffer>,
|
||||
background_vertex_count: u32,
|
||||
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||
paper_buffer: wgpu::Buffer,
|
||||
pattern_buffer: Option<wgpu::Buffer>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
@@ -160,6 +163,9 @@ 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,
|
||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||
msaa_samples: u32,
|
||||
msaa_view: Option<wgpu::TextureView>,
|
||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||
presentation_warned: bool,
|
||||
pub device: wgpu::Device,
|
||||
@@ -184,9 +190,10 @@ pub struct CanvasState {
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
|
||||
pub background_buffer: wgpu::Buffer,
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pattern_pipeline: wgpu::RenderPipeline,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
@@ -210,6 +217,9 @@ pub struct CanvasState {
|
||||
pub live_vertex_capacity: usize,
|
||||
pub live_vertex_count: u32,
|
||||
pub live_samples: Vec<StylusSample>,
|
||||
/// Ungefilterte Eingabepunkte des Live-Strichs (nach dem Lineal): Grundlage des fertigen
|
||||
/// Strichs, damit kleine Schleifen nicht durch die Filterverzögerung schrumpfen
|
||||
live_raw_samples: Vec<StylusSample>,
|
||||
pub live_control_points: Vec<ControlPoint>,
|
||||
pub live_vertices: Vec<InkVertex>,
|
||||
pub filter: OneEuroFilter,
|
||||
@@ -240,7 +250,6 @@ pub struct CanvasState {
|
||||
impl CanvasState {
|
||||
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
||||
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
||||
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
|
||||
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
||||
|
||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||
@@ -323,6 +332,18 @@ impl CanvasState {
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||
let msaa_samples = if adapter
|
||||
.get_texture_format_features(config.format)
|
||||
.flags
|
||||
.sample_count_supported(MSAA_SAMPLES)
|
||||
{
|
||||
MSAA_SAMPLES
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||
|
||||
// 5. Shader & Bind Group Layout
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Ink WGSL Shader"),
|
||||
@@ -403,7 +424,10 @@ impl CanvasState {
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
@@ -435,7 +459,10 @@ impl CanvasState {
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
@@ -451,19 +478,46 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(
|
||||
let bg_mesh = generate_pattern_quad(
|
||||
current_background,
|
||||
paper_theme.pattern_color(),
|
||||
crate::document::A4_SIZE,
|
||||
);
|
||||
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,
|
||||
label: Some("Pattern Quad Buffer"),
|
||||
size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
|
||||
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Pattern Pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_pattern",
|
||||
buffers: &[PatternVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_pattern",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
||||
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Live Stroke Vertex Buffer"),
|
||||
@@ -484,6 +538,7 @@ impl CanvasState {
|
||||
&shader,
|
||||
&bind_group_layout,
|
||||
config.format,
|
||||
msaa_samples,
|
||||
);
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
@@ -492,6 +547,8 @@ impl CanvasState {
|
||||
Ok(Self {
|
||||
surface,
|
||||
adapter,
|
||||
msaa_samples,
|
||||
msaa_view,
|
||||
presentation_warned: false,
|
||||
device,
|
||||
queue,
|
||||
@@ -508,8 +565,8 @@ impl CanvasState {
|
||||
current_stroke_width_mm: 0.5,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
pattern_pipeline,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
@@ -523,6 +580,7 @@ impl CanvasState {
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
live_samples: Vec::with_capacity(256),
|
||||
live_raw_samples: Vec::with_capacity(256),
|
||||
live_control_points: Vec::with_capacity(256),
|
||||
live_vertices: Vec::with_capacity(1024),
|
||||
filter: OneEuroFilter::default(),
|
||||
@@ -639,6 +697,7 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
}
|
||||
@@ -809,23 +868,12 @@ impl CanvasState {
|
||||
|
||||
/// 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(
|
||||
let mesh = generate_pattern_quad(
|
||||
self.current_background,
|
||||
self.pattern.color(self.paper_theme),
|
||||
self.page_size,
|
||||
);
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
self.background_vertex_capacity = mesh.len() * 2;
|
||||
self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer (Reallocated)"),
|
||||
size: (self.background_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !mesh.is_empty() {
|
||||
self.queue
|
||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||
@@ -933,20 +981,39 @@ impl CanvasState {
|
||||
buffer
|
||||
})
|
||||
};
|
||||
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
||||
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
background.extend(generate_background_mesh(
|
||||
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
let pattern = generate_pattern_quad(
|
||||
page.background,
|
||||
self.pattern.color(self.paper_theme),
|
||||
page.size,
|
||||
));
|
||||
);
|
||||
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Paper Buffer"),
|
||||
size: std::mem::size_of_val(paper.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
|
||||
let pattern_buffer = (!pattern.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Pattern Buffer"),
|
||||
size: std::mem::size_of_val(pattern.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
|
||||
buffer
|
||||
});
|
||||
NeighborPage {
|
||||
fingerprint,
|
||||
offset: glam::Vec2::ZERO,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||
background_vertex_count: background.len() as u32,
|
||||
paper_buffer,
|
||||
pattern_buffer,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
}
|
||||
@@ -1051,6 +1118,8 @@ impl CanvasState {
|
||||
.ruler
|
||||
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
self.live_raw_samples.clear();
|
||||
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
@@ -1076,6 +1145,7 @@ impl CanvasState {
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||
|
||||
@@ -1098,6 +1168,25 @@ impl CanvasState {
|
||||
self.update_live_mesh();
|
||||
}
|
||||
|
||||
/// Merkt sich einen ungefilterten Eingabepunkt (Punkte näher als 0,03 mm am vorigen
|
||||
/// entfallen).
|
||||
fn push_raw_sample(&mut self, x: f32, y: f32, pressure: f32, timestamp: f64) {
|
||||
let position = glam::Vec2::new(x, y);
|
||||
if !position.is_finite() || !pressure.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = self.live_raw_samples.last() {
|
||||
if (position - last.position).length_squared() < 0.09 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.live_raw_samples.push(StylusSample {
|
||||
position,
|
||||
pressure: pressure.clamp(0.0, 1.0),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||
match self.ruler_lock {
|
||||
@@ -1114,6 +1203,7 @@ impl CanvasState {
|
||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||
pub fn cancel_stroke(&mut self) {
|
||||
self.live_samples.clear();
|
||||
self.live_raw_samples.clear();
|
||||
self.live_control_points.clear();
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
@@ -1131,10 +1221,16 @@ impl CanvasState {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen)
|
||||
// Post-Stroke Finalisierung (Glättung, RDP, Ecken, runde Kappen) aus den ungefilterten
|
||||
// Punkten: Der 1€-Filter dient nur der Anzeige während des Schreibens
|
||||
let source = if self.live_raw_samples.is_empty() {
|
||||
&self.live_samples
|
||||
} else {
|
||||
&self.live_raw_samples
|
||||
};
|
||||
let stroke = spline::finalize_stroke_with_config(
|
||||
stroke_id,
|
||||
&self.live_samples,
|
||||
source,
|
||||
self.current_ink_color,
|
||||
&self.spline_config,
|
||||
);
|
||||
@@ -1144,6 +1240,7 @@ impl CanvasState {
|
||||
|
||||
// Temporären GPU-Live-Buffer vollständig leeren
|
||||
self.live_samples.clear();
|
||||
self.live_raw_samples.clear();
|
||||
self.live_control_points.clear();
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
@@ -1230,12 +1327,17 @@ impl CanvasState {
|
||||
{
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Ink Canvas Render Pass"),
|
||||
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
view: self.msaa_view.as_ref().unwrap_or(&view),
|
||||
resolve_target: self.msaa_view.as_ref().map(|_| &view),
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||
store: wgpu::StoreOp::Store,
|
||||
store: if self.msaa_view.is_some() {
|
||||
wgpu::StoreOp::Discard
|
||||
} else {
|
||||
wgpu::StoreOp::Store
|
||||
},
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
@@ -1247,9 +1349,12 @@ impl CanvasState {
|
||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
if let Some(buffer) = &neighbor.background_buffer {
|
||||
render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
if let Some(buffer) = &neighbor.pattern_buffer {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||
render_pass.draw(0..6, 0..1);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
@@ -1273,6 +1378,7 @@ impl CanvasState {
|
||||
|
||||
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
||||
if self.background_vertex_count > 0 {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||
}
|
||||
@@ -1342,6 +1448,35 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||
const MSAA_SAMPLES: u32 = 4;
|
||||
|
||||
/// Zwischenbild für das Multisampling in Fenstergröße; `None` ohne Multisampling.
|
||||
fn create_msaa_view(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
samples: u32,
|
||||
) -> Option<wgpu::TextureView> {
|
||||
(samples > 1).then(|| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("MSAA Color Target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width.max(1),
|
||||
height: config.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
})
|
||||
}
|
||||
|
||||
/// Zu rasterndes Textfeld.
|
||||
struct TextSpec {
|
||||
text: crate::text::TextBox,
|
||||
|
||||
+91
-15
@@ -10,10 +10,13 @@ use std::f32::consts::PI;
|
||||
|
||||
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
||||
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
||||
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
|
||||
pub const DEFAULT_BETA: f32 = 0.05;
|
||||
/// Standard-Grenzfrequenz für die Ableitung in Hz
|
||||
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
|
||||
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
|
||||
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
|
||||
/// werden kaum noch verkleinert
|
||||
pub const DEFAULT_BETA: f32 = 0.15;
|
||||
/// Standard-Grenzfrequenz für die Geschwindigkeitsschätzung in Hz (reagiert nach wenigen
|
||||
/// Millisekunden auf schnellere Bewegung)
|
||||
pub const DEFAULT_D_CUTOFF: f32 = 8.0;
|
||||
|
||||
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
||||
///
|
||||
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
|
||||
}
|
||||
|
||||
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
||||
///
|
||||
/// Die Position wird mit der Geschwindigkeit in der Ebene gefiltert (nicht je Achse):
|
||||
/// Auf einer Kreisbahn ist eine Achse an den Umkehrpunkten in Ruhe, eine Filterung je
|
||||
/// Achse würde dort stark glätten und Schleifen zusammenziehen.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OneEuroFilter {
|
||||
pub fc_min: f32,
|
||||
pub beta: f32,
|
||||
pub d_cutoff: f32,
|
||||
x_filter: OneEuroFilter1D,
|
||||
y_filter: OneEuroFilter1D,
|
||||
pos_prev: Option<glam::Vec2>,
|
||||
speed_prev: f32,
|
||||
t_prev: Option<f64>,
|
||||
p_filter: OneEuroFilter1D,
|
||||
last_output: Option<(f32, f32, f32)>,
|
||||
}
|
||||
@@ -175,8 +183,9 @@ impl OneEuroFilter {
|
||||
fc_min,
|
||||
beta,
|
||||
d_cutoff,
|
||||
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
pos_prev: None,
|
||||
speed_prev: 0.0,
|
||||
t_prev: None,
|
||||
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
last_output: None,
|
||||
}
|
||||
@@ -184,8 +193,9 @@ impl OneEuroFilter {
|
||||
|
||||
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
||||
pub fn reset(&mut self) {
|
||||
self.x_filter.reset();
|
||||
self.y_filter.reset();
|
||||
self.pos_prev = None;
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = None;
|
||||
self.p_filter.reset();
|
||||
self.last_output = None;
|
||||
}
|
||||
@@ -202,8 +212,8 @@ impl OneEuroFilter {
|
||||
// Druck immer auf [0.0, 1.0] klemmen
|
||||
let p_in = pressure.clamp(0.0, 1.0);
|
||||
|
||||
let x_out = self.x_filter.filter(x, t);
|
||||
let y_out = self.y_filter.filter(y, t);
|
||||
let pos = self.filter_position(glam::Vec2::new(x, y), t);
|
||||
let (x_out, y_out) = (pos.x, pos.y);
|
||||
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
||||
|
||||
let out = (x_out, y_out, p_out);
|
||||
@@ -211,6 +221,51 @@ impl OneEuroFilter {
|
||||
out
|
||||
}
|
||||
|
||||
/// 1€-Filter der Position mit der Geschwindigkeit in der Ebene.
|
||||
fn filter_position(&mut self, x: glam::Vec2, t: f64) -> glam::Vec2 {
|
||||
let (Some(prev), Some(prev_t)) = (self.pos_prev, self.t_prev) else {
|
||||
self.pos_prev = Some(x);
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = Some(t);
|
||||
return x;
|
||||
};
|
||||
let raw_dt = t - prev_t;
|
||||
// Lange Pause: neu beginnen statt über die Pause hinweg zu glätten
|
||||
if raw_dt > 1.0 {
|
||||
self.pos_prev = Some(x);
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = Some(t);
|
||||
return x;
|
||||
}
|
||||
let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() {
|
||||
1e-4_f32
|
||||
} else {
|
||||
raw_dt as f32
|
||||
};
|
||||
self.t_prev = Some(t);
|
||||
|
||||
let mut speed = (x - prev).length() / dt;
|
||||
if !speed.is_finite() {
|
||||
speed = 0.0;
|
||||
}
|
||||
let a_d = smoothing_factor(dt, self.d_cutoff);
|
||||
let speed_hat = a_d * speed + (1.0 - a_d) * self.speed_prev;
|
||||
|
||||
let fc = (self.fc_min + self.beta * speed_hat).max(self.fc_min);
|
||||
let a = smoothing_factor(dt, fc);
|
||||
let mut out = prev + (x - prev) * a;
|
||||
if !out.is_finite() {
|
||||
out = prev;
|
||||
}
|
||||
self.pos_prev = Some(out);
|
||||
self.speed_prev = if speed_hat.is_finite() {
|
||||
speed_hat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
out
|
||||
}
|
||||
|
||||
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
|
||||
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
|
||||
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
|
||||
@@ -279,6 +334,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_loops_do_not_shrink() {
|
||||
// Kreis mit 0,8 mm Radius in 0,1 s: die Filterung in der Ebene folgt ihm
|
||||
let mut filter = OneEuroFilter::default();
|
||||
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||
let mut radii = Vec::new();
|
||||
for i in 0..=(period * hz) as usize * 2 {
|
||||
let t = i as f64 / hz;
|
||||
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||
let p = glam::Vec2::new(500.0 + r * a.sin(), 500.0 - r * a.cos());
|
||||
let (out, _) = filter.filter_point(p, 0.5, t);
|
||||
if t > period {
|
||||
radii.push((out - glam::Vec2::new(500.0, 500.0)).length());
|
||||
}
|
||||
}
|
||||
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
|
||||
assert!(mean > r * 0.9, "Schleife geschrumpft: {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_frequency_jitter() {
|
||||
let mut filter = OneEuroFilter::default();
|
||||
@@ -286,7 +360,8 @@ mod tests {
|
||||
let base_x = 100.0_f32;
|
||||
let base_y = 50.0_f32;
|
||||
let base_p = 0.5_f32;
|
||||
let jitter_amplitude = 2.0_f32;
|
||||
// Realistisches Digitizer-Rauschen (±0,05 mm) bei ruhendem Stift
|
||||
let jitter_amplitude = 0.5_f32;
|
||||
|
||||
let num_samples = 120;
|
||||
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
||||
@@ -331,9 +406,10 @@ mod tests {
|
||||
.sum::<f32>()
|
||||
/ count;
|
||||
|
||||
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
|
||||
// Der 1€-Filter muss das hochfrequente Jitter deutlich dämpfen (der fertige Strich
|
||||
// wird zusätzlich symmetrisch geglättet)
|
||||
assert!(
|
||||
filtered_variance < raw_variance * 0.05,
|
||||
filtered_variance < raw_variance * 0.2,
|
||||
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -102,6 +102,7 @@ impl ImageLayer {
|
||||
shader: &wgpu::ShaderModule,
|
||||
camera_layout: &wgpu::BindGroupLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
sample_count: u32,
|
||||
) -> Self {
|
||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Image Texture Bind Group Layout"),
|
||||
@@ -151,7 +152,10 @@ impl ImageLayer {
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: sample_count,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
|
||||
@@ -77,3 +77,84 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(image_texture, image_sampler, in.uv);
|
||||
}
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
|
||||
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
|
||||
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
|
||||
struct PatternInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
|
||||
@location(2) params: vec4<f32>,
|
||||
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
struct PatternOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) page: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) params: vec4<f32>,
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_pattern(in: PatternInput) -> PatternOutput {
|
||||
var out: PatternOutput;
|
||||
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.page = in.position;
|
||||
out.color = in.color;
|
||||
out.params = in.params;
|
||||
out.bounds = in.bounds;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
|
||||
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
|
||||
let half_px = max(half_width / upp, 0.5);
|
||||
let d = abs(c - line) / upp;
|
||||
return clamp(half_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
||||
let p = in.page;
|
||||
let kind = in.params.x;
|
||||
let spacing = in.params.y;
|
||||
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
|
||||
let size = in.bounds.xy;
|
||||
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
|
||||
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
|
||||
|
||||
var coverage = 0.0;
|
||||
if (kind < 1.5) {
|
||||
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
|
||||
let k = round(p / spacing);
|
||||
let line = k * spacing;
|
||||
if (k.x >= 1.0 && line.x < size.x - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
|
||||
}
|
||||
if (k.y >= 1.0 && line.y < size.y - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
|
||||
}
|
||||
} else if (kind < 2.5) {
|
||||
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
|
||||
let margin = in.bounds.z;
|
||||
let k = round((p.y - margin) / spacing);
|
||||
let line = margin + k * spacing;
|
||||
if (k >= 0.0 && line <= size.y - margin + 0.01) {
|
||||
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
|
||||
}
|
||||
} else {
|
||||
// Punkte: an den Kreuzungen des Karos
|
||||
let k = round(p / spacing);
|
||||
let point = k * spacing;
|
||||
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
|
||||
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
|
||||
let d = length(p - point) / upp;
|
||||
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade);
|
||||
}
|
||||
+66
-7
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
|
||||
|
||||
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
||||
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
||||
/// Ecken nur zwischen Abschnitten, die nach der Ausdünnung mindestens so lang sind (0,6 mm).
|
||||
/// Kleine Schleifen („o“, „a“, „e“) bestehen aus kurzen Abschnitten mit großem Knickwinkel;
|
||||
/// würden sie als Ecken getrennt, würden sie zu spitzen Zacken statt Rundungen.
|
||||
pub const CORNER_MIN_SEGMENT_UNITS: f32 = 6.0;
|
||||
|
||||
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
||||
#[repr(C)]
|
||||
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
|
||||
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
||||
let pt = control_points[0];
|
||||
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
||||
let segments = 8;
|
||||
let segments = 20;
|
||||
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
||||
|
||||
for i in 0..=segments {
|
||||
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||
smoothed
|
||||
}
|
||||
|
||||
/// Glättet die Positionen mit dem Fenster [0.25, 0.5, 0.25] gegen Digitizer-Rauschen.
|
||||
/// Symmetrisch (ohne Verzögerung), daher bleiben auch kleine Schleifen rund. Anfang, Ende
|
||||
/// und Punkte mit scharfem Knick (über [`CORNER_ANGLE_THRESHOLD_DEG`]) bleiben unverändert,
|
||||
/// damit Ecken spitz bleiben.
|
||||
pub fn smooth_positions(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||
if samples.len() < 3 {
|
||||
return samples.to_vec();
|
||||
}
|
||||
let cos_limit = CORNER_ANGLE_THRESHOLD_DEG.to_radians().cos();
|
||||
let mut smoothed = samples.to_vec();
|
||||
for i in 1..samples.len() - 1 {
|
||||
let (a, b, c) = (
|
||||
samples[i - 1].position,
|
||||
samples[i].position,
|
||||
samples[i + 1].position,
|
||||
);
|
||||
let (d_in, d_out) = (b - a, c - b);
|
||||
if d_in.length_squared() > 1e-8 && d_out.length_squared() > 1e-8 {
|
||||
let cos = d_in.normalize().dot(d_out.normalize());
|
||||
if cos < cos_limit {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
smoothed[i].position = 0.25 * a + 0.5 * b + 0.25 * c;
|
||||
}
|
||||
smoothed
|
||||
}
|
||||
|
||||
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
||||
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
||||
if end <= start + 1 {
|
||||
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`.
|
||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`
|
||||
/// (nur zwischen Segmenten von mindestens [`CORNER_MIN_SEGMENT_UNITS`] Länge).
|
||||
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
||||
if samples.len() < 3 {
|
||||
return Vec::new();
|
||||
@@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
||||
let l_in = v_in.length();
|
||||
let l_out = v_out.length();
|
||||
|
||||
if l_in > 1e-4 && l_out > 1e-4 {
|
||||
if l_in >= CORNER_MIN_SEGMENT_UNITS && l_out >= CORNER_MIN_SEGMENT_UNITS {
|
||||
let d_in = v_in / l_in;
|
||||
let d_out = v_out / l_out;
|
||||
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
||||
@@ -566,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
const CAP_STEPS: usize = 4;
|
||||
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
|
||||
const CAP_STEPS: usize = 10;
|
||||
let mut vertices = Vec::new();
|
||||
|
||||
// 3. Runde Start-Kappe vor dem ersten Run
|
||||
@@ -626,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
|
||||
|
||||
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
||||
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
||||
/// 2. Druckglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
/// 2. Druck- und Positionsglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||
@@ -649,8 +683,8 @@ pub fn finalize_stroke_with_config(
|
||||
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
||||
}
|
||||
|
||||
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
let smoothed = smooth_pressure(raw_samples);
|
||||
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
|
||||
|
||||
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
||||
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
||||
@@ -866,6 +900,31 @@ mod tests {
|
||||
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_loops_stay_round() {
|
||||
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
|
||||
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||
let center = |t: f64| glam::Vec2::new(500.0 + t as f32 * 40.0, 500.0);
|
||||
let raw: Vec<StylusSample> = (0..=((period * hz) as usize))
|
||||
.map(|i| {
|
||||
let t = i as f64 / hz;
|
||||
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||
StylusSample {
|
||||
position: center(t) + glam::Vec2::new(r * a.sin(), -r * a.cos()),
|
||||
pressure: 0.6,
|
||||
timestamp: t,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let stroke =
|
||||
finalize_stroke_with_config(1, &raw, [0.0; 4], &SplineConfig::from_width_units(5.0));
|
||||
for p in &stroke.points {
|
||||
let d = (p.position - center(p.timestamp)).length();
|
||||
assert!((d - r).abs() < 0.6, "Schleife verformt: Abstand {d}");
|
||||
}
|
||||
assert!(detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_smoothing() {
|
||||
let samples = vec![
|
||||
|
||||
@@ -3089,14 +3089,18 @@ pub fn render_ui(
|
||||
)
|
||||
};
|
||||
|
||||
let frame_resp = egui::Frame::none()
|
||||
// Nur der Titelbereich öffnet das Notizbuch: Eine Klickfläche über der
|
||||
// ganzen Zeile würde (als zuletzt angelegtes Element) die Klicks auf
|
||||
// Umbenennen und Löschen abfangen
|
||||
let mut open_clicked = false;
|
||||
egui::Frame::none()
|
||||
.fill(bg)
|
||||
.stroke(border)
|
||||
.rounding(egui::Rounding::same(8.0))
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let title_area = ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&summary.title)
|
||||
@@ -3121,6 +3125,11 @@ pub fn render_ui(
|
||||
.color(egui::Color32::from_gray(160)),
|
||||
);
|
||||
});
|
||||
open_clicked = title_area
|
||||
.response
|
||||
.interact(egui::Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked();
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if summary.status == crate::storage::NotebookStatus::Damaged {
|
||||
@@ -3161,7 +3170,7 @@ pub fn render_ui(
|
||||
});
|
||||
|
||||
if summary.status != crate::storage::NotebookStatus::Damaged
|
||||
&& frame_resp.response.interact(egui::Sense::click()).clicked()
|
||||
&& open_clicked
|
||||
&& !is_current
|
||||
{
|
||||
actions.push(UiAction::OpenNotebook(summary.id.clone()));
|
||||
@@ -3969,6 +3978,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_rename_delete_and_open_are_clickable() {
|
||||
let notebook = Notebook::new();
|
||||
let fresh = || {
|
||||
let mut state = UiState::new();
|
||||
state.is_document_menu_open = true;
|
||||
state.available_notebooks = ["default", "zweites"]
|
||||
.iter()
|
||||
.map(|id| NotebookSummary {
|
||||
id: id.to_string(),
|
||||
title: format!("Notizbuch {id}"),
|
||||
page_count: 1,
|
||||
modified_at: 0,
|
||||
status: Default::default(),
|
||||
error: None,
|
||||
})
|
||||
.collect();
|
||||
state
|
||||
};
|
||||
let mut probe = fresh();
|
||||
render_frames(&mut probe, ¬ebook, 10);
|
||||
let modal = probe.document_modal_rect.expect("Notizbuch-Dialog offen");
|
||||
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let (mut deleted, mut renamed, mut opened) = (false, false, false);
|
||||
let mut y = modal.min.y + 4.0;
|
||||
while y < modal.max.y && !(deleted && renamed && opened) {
|
||||
let mut x = modal.min.x + 4.0;
|
||||
while x < modal.max.x {
|
||||
let mut state = fresh();
|
||||
let ctx = render_frames(&mut state, ¬ebook, 3);
|
||||
let pos = egui::pos2(x, y);
|
||||
let mut actions = Vec::new();
|
||||
for pressed in [true, false] {
|
||||
ctx.begin_frame(egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1280.0, 850.0),
|
||||
)),
|
||||
events: vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
actions.extend(render_ui(
|
||||
&ctx,
|
||||
&mut state,
|
||||
¬ebook,
|
||||
AutosaveStatus::Saved,
|
||||
&updater,
|
||||
));
|
||||
let _ = ctx.end_frame();
|
||||
}
|
||||
deleted |= actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, UiAction::DeleteNotebook(_)));
|
||||
opened |= actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, UiAction::OpenNotebook(_)));
|
||||
renamed |= state.renaming_notebook_id.is_some();
|
||||
x += 8.0;
|
||||
}
|
||||
y += 6.0;
|
||||
}
|
||||
assert!(deleted, "Löschen-Knopf reagiert nicht");
|
||||
assert!(renamed, "Umbenennen-Knopf reagiert nicht");
|
||||
assert!(opened, "Titel öffnet das Notizbuch nicht");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_buttons_react_to_raw_pointer_events() {
|
||||
// Gleicher Weg wie die Stift-Eingabe: PointerMoved + PointerButton in die RawInput
|
||||
|
||||
Reference in new issue
Block a user