feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve
- Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste) - Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen" gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt. - Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt) - Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau - Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern - Test prüft den WGSL-Shader mit naga Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -1057,6 +1057,7 @@ dependencies = [
|
|||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
|
"naga",
|
||||||
"octotablet",
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
|
|||||||
@@ -53,3 +53,5 @@ strip = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
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"] }
|
||||||
|
|||||||
+195
-36
@@ -1,5 +1,7 @@
|
|||||||
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
|
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).
|
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
||||||
pub const LINED_SPACING: f32 = 75.0;
|
pub const LINED_SPACING: f32 = 75.0;
|
||||||
|
|
||||||
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
|
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||||
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
|
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||||
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
|
///
|
||||||
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||||
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
|
#[serde(rename_all = "lowercase")]
|
||||||
r: 0.16,
|
pub enum PaperTheme {
|
||||||
g: 0.16,
|
#[default]
|
||||||
b: 0.18,
|
White,
|
||||||
a: 1.0,
|
/// 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.
|
impl PaperTheme {
|
||||||
pub fn generate_paper_quad() -> Vec<InkVertex> {
|
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![
|
vec![
|
||||||
InkVertex::new(0.0, 0.0, PAPER_COLOR),
|
InkVertex::new(0.0, 0.0, color),
|
||||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
|
fn push_quad(
|
||||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
vertices: &mut Vec<InkVertex>,
|
||||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
color: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
x_min: f32,
|
||||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
y_min: f32,
|
||||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
x_max: f32,
|
||||||
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
|
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.
|
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
|
||||||
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
|
||||||
match background {
|
match background {
|
||||||
PageBackground::Blank => Vec::new(),
|
PageBackground::Blank => Vec::new(),
|
||||||
PageBackground::Grid5mm => {
|
PageBackground::Grid5mm => {
|
||||||
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
// Vertikale Linien alle 5 mm
|
// Vertikale Linien alle 5 mm
|
||||||
let mut x = GRID_5MM_SPACING;
|
let mut x = GRID_5MM_SPACING;
|
||||||
while x < PAGE_WIDTH {
|
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;
|
x += GRID_5MM_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontale Linien alle 5 mm
|
// Horizontale Linien alle 5 mm
|
||||||
let mut y = GRID_5MM_SPACING;
|
let mut y = GRID_5MM_SPACING;
|
||||||
while y < PAGE_HEIGHT {
|
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;
|
y += GRID_5MM_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
|
|
||||||
let mut y = margin_top;
|
let mut y = margin_top;
|
||||||
while y <= PAGE_HEIGHT - margin_bottom {
|
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;
|
y += LINED_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
while x < PAGE_WIDTH {
|
while x < PAGE_WIDTH {
|
||||||
push_quad(
|
push_quad(
|
||||||
&mut vertices,
|
&mut vertices,
|
||||||
|
color,
|
||||||
x - half_d,
|
x - half_d,
|
||||||
y - half_d,
|
y - half_d,
|
||||||
x + half_d,
|
x + half_d,
|
||||||
@@ -119,18 +222,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_paper_quad_generation() {
|
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.len(), 6);
|
||||||
assert_eq!(quad[0].position, [0.0, 0.0]);
|
assert_eq!(quad[0].position, [0.0, 0.0]);
|
||||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_meshes() {
|
fn test_background_meshes() {
|
||||||
let blank = generate_background_mesh(PageBackground::Blank);
|
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
|
||||||
assert!(blank.is_empty());
|
assert!(blank.is_empty());
|
||||||
|
|
||||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
|
||||||
assert!(!grid.is_empty());
|
assert!(!grid.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
grid.len() % 6,
|
grid.len() % 6,
|
||||||
@@ -138,12 +243,66 @@ mod tests {
|
|||||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
"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!(!lined.is_empty());
|
||||||
assert_eq!(lined.len() % 6, 0);
|
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!(!dots.is_empty());
|
||||||
assert_eq!(dots.len() % 6, 0);
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-14
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
|||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
use winit::window::Window;
|
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::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
@@ -58,6 +58,8 @@ impl InkVertex {
|
|||||||
pub struct CameraUniforms {
|
pub struct CameraUniforms {
|
||||||
pub transform_scale: [f32; 2],
|
pub transform_scale: [f32; 2],
|
||||||
pub transform_offset: [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].
|
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
||||||
@@ -135,7 +137,6 @@ pub struct CanvasState {
|
|||||||
pub camera_bind_group: wgpu::BindGroup,
|
pub camera_bind_group: wgpu::BindGroup,
|
||||||
pub current_ink_color: [f32; 4],
|
pub current_ink_color: [f32; 4],
|
||||||
pub current_stroke_width_mm: f32,
|
pub current_stroke_width_mm: f32,
|
||||||
pub current_tool: crate::document::StrokeTool,
|
|
||||||
|
|
||||||
// Statische Hintergrund-Geometrie
|
// Statische Hintergrund-Geometrie
|
||||||
pub paper_buffer: wgpu::Buffer,
|
pub paper_buffer: wgpu::Buffer,
|
||||||
@@ -143,6 +144,10 @@ pub struct CanvasState {
|
|||||||
pub background_vertex_capacity: usize,
|
pub background_vertex_capacity: usize,
|
||||||
pub background_vertex_count: u32,
|
pub background_vertex_count: u32,
|
||||||
pub current_background: PageBackground,
|
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)
|
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||||
pub live_vertex_buffer: wgpu::Buffer,
|
pub live_vertex_buffer: wgpu::Buffer,
|
||||||
@@ -276,6 +281,7 @@ impl CanvasState {
|
|||||||
let camera_uniforms = CameraUniforms {
|
let camera_uniforms = CameraUniforms {
|
||||||
transform_scale: scale,
|
transform_scale: scale,
|
||||||
transform_offset: offset,
|
transform_offset: offset,
|
||||||
|
ink_params: [0.0; 4],
|
||||||
};
|
};
|
||||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Camera Uniform Buffer"),
|
label: Some("Camera Uniform Buffer"),
|
||||||
@@ -312,7 +318,7 @@ impl CanvasState {
|
|||||||
},
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_main",
|
entry_point: "fs_ink",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: config.format,
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
@@ -366,7 +372,8 @@ impl CanvasState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 6. Statische Buffer
|
// 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 {
|
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Paper Quad Buffer"),
|
label: Some("Paper Quad Buffer"),
|
||||||
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -376,7 +383,7 @@ impl CanvasState {
|
|||||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||||
|
|
||||||
let current_background = PageBackground::Grid5mm;
|
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 {
|
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Background Mesh Buffer"),
|
label: Some("Background Mesh Buffer"),
|
||||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -420,12 +427,13 @@ impl CanvasState {
|
|||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
current_ink_color: default_ink_color,
|
current_ink_color: default_ink_color,
|
||||||
current_stroke_width_mm: 0.5,
|
current_stroke_width_mm: 0.5,
|
||||||
current_tool: crate::document::StrokeTool::Pen,
|
|
||||||
paper_buffer,
|
paper_buffer,
|
||||||
background_buffer,
|
background_buffer,
|
||||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||||
background_vertex_count: bg_mesh.len() as u32,
|
background_vertex_count: bg_mesh.len() as u32,
|
||||||
current_background,
|
current_background,
|
||||||
|
paper_theme,
|
||||||
|
adapt_ink: false,
|
||||||
live_vertex_buffer,
|
live_vertex_buffer,
|
||||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||||
live_vertex_count: 0,
|
live_vertex_count: 0,
|
||||||
@@ -453,6 +461,16 @@ impl CanvasState {
|
|||||||
let camera_uniforms = CameraUniforms {
|
let camera_uniforms = CameraUniforms {
|
||||||
transform_scale: scale,
|
transform_scale: scale,
|
||||||
transform_offset: offset,
|
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.queue.write_buffer(
|
||||||
&self.camera_buffer,
|
&self.camera_buffer,
|
||||||
@@ -587,9 +605,19 @@ impl CanvasState {
|
|||||||
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt das aktive Zeichenwerkzeug für den Stift.
|
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
|
||||||
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
|
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
|
||||||
self.current_tool = tool;
|
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.
|
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||||
@@ -598,7 +626,13 @@ impl CanvasState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.current_background = background;
|
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;
|
self.background_vertex_count = mesh.len() as u32;
|
||||||
|
|
||||||
if mesh.len() > self.background_vertex_capacity {
|
if mesh.len() > self.background_vertex_capacity {
|
||||||
@@ -728,7 +762,6 @@ impl CanvasState {
|
|||||||
&self.spline_config,
|
&self.spline_config,
|
||||||
);
|
);
|
||||||
let mut stroke = stroke;
|
let mut stroke = stroke;
|
||||||
stroke.tool = self.current_tool;
|
|
||||||
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||||
stroke.finalized = true;
|
stroke.finalized = true;
|
||||||
|
|
||||||
@@ -824,7 +857,7 @@ impl CanvasState {
|
|||||||
view: &view,
|
view: &view,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
ops: wgpu::Operations {
|
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,
|
store: wgpu::StoreOp::Store,
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
@@ -836,7 +869,7 @@ impl CanvasState {
|
|||||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
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_pipeline(&self.triangle_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||||
render_pass.draw(0..6, 0..1);
|
render_pass.draw(0..6, 0..1);
|
||||||
@@ -948,7 +981,40 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_camera_uniforms() {
|
fn test_camera_uniforms() {
|
||||||
let _u = CameraUniforms::default();
|
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]
|
#[test]
|
||||||
|
|||||||
+92
-3
@@ -113,6 +113,39 @@ pub enum StrokeTool {
|
|||||||
Shape,
|
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.
|
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Stroke {
|
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).
|
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
||||||
pub fn erase_at(
|
pub fn erase_at(
|
||||||
@@ -477,11 +511,14 @@ impl Page {
|
|||||||
eraser_pos: glam::Vec2,
|
eraser_pos: glam::Vec2,
|
||||||
eraser_radius: f32,
|
eraser_radius: f32,
|
||||||
max_stroke_radius: f32,
|
max_stroke_radius: f32,
|
||||||
|
mode: EraserMode,
|
||||||
) -> Vec<Stroke> {
|
) -> Vec<Stroke> {
|
||||||
let mut hit_indices = Vec::new();
|
let mut hit_indices = Vec::new();
|
||||||
|
|
||||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
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);
|
hit_indices.push(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1040,7 +1077,7 @@ mod tests {
|
|||||||
assert_eq!(page.strokes.len(), 2);
|
assert_eq!(page.strokes.len(), 2);
|
||||||
|
|
||||||
// Radieren bei (30, 12): Trifft nur s1
|
// 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.len(), 1);
|
||||||
assert_eq!(removed[0].id, 1);
|
assert_eq!(removed[0].id, 1);
|
||||||
assert_eq!(page.strokes.len(), 1);
|
assert_eq!(page.strokes.len(), 1);
|
||||||
@@ -1056,6 +1093,58 @@ mod tests {
|
|||||||
assert_eq!(page.strokes[0].id, 2);
|
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]
|
#[test]
|
||||||
fn test_page_isolation() {
|
fn test_page_isolation() {
|
||||||
let mut nb = Notebook::new();
|
let mut nb = Notebook::new();
|
||||||
|
|||||||
+46
-20
@@ -31,6 +31,19 @@ use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
|||||||
use ink_notes::updater::UpdaterController;
|
use ink_notes::updater::UpdaterController;
|
||||||
|
|
||||||
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
|
/// 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(
|
fn update_window_title(
|
||||||
window: &Window,
|
window: &Window,
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
@@ -121,7 +134,8 @@ fn commit_stroke_to_notebook(
|
|||||||
canvas.finish_stroke(new_id)
|
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();
|
let page = notebook.current_page_mut();
|
||||||
page.add_stroke(stroke);
|
page.add_stroke(stroke);
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
@@ -259,6 +273,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.map(UiSettings::load_from)
|
.map(UiSettings::load_from)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ui_state.sidebar_side = ui_settings.sidebar_side;
|
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.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
@@ -290,6 +307,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
canvas.set_stroke_color(ui_state.pen_color);
|
canvas.set_stroke_color(ui_state.pen_color);
|
||||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
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 start_time = Instant::now();
|
||||||
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
@@ -471,7 +489,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
cursor_screen_pos = (sx, sy);
|
cursor_screen_pos = (sx, sy);
|
||||||
|
|
||||||
if let Some(p) = pose.pressure.get() {
|
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),
|
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||||
@@ -498,8 +517,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let removed = page.erase_at(
|
let removed = page.erase_at(
|
||||||
page_pt,
|
page_pt,
|
||||||
eraser_radius,
|
eraser_radius,
|
||||||
stroke_max_radius,
|
stroke_max_radius, ui_state.eraser_mode);
|
||||||
);
|
|
||||||
if !removed.is_empty() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -566,8 +584,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let removed = page.erase_at(
|
let removed = page.erase_at(
|
||||||
page_pt,
|
page_pt,
|
||||||
eraser_radius,
|
eraser_radius,
|
||||||
stroke_max_radius,
|
stroke_max_radius, ui_state.eraser_mode);
|
||||||
);
|
|
||||||
if !removed.is_empty() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -1187,7 +1204,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
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() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -1455,7 +1472,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
} else if is_erasing {
|
} else if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
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() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -1516,15 +1533,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let now = start_time.elapsed().as_secs_f64();
|
let now = start_time.elapsed().as_secs_f64();
|
||||||
let tx = touch.location.x as f32;
|
let tx = touch.location.x as f32;
|
||||||
let ty = touch.location.y as f32;
|
let ty = touch.location.y as f32;
|
||||||
let pressure = match touch.force {
|
let raw_pressure = match touch.force {
|
||||||
Some(winit::event::Force::Normalized(f)) => f as f32,
|
Some(winit::event::Force::Normalized(f)) => Some(f as f32),
|
||||||
Some(winit::event::Force::Calibrated {
|
Some(winit::event::Force::Calibrated {
|
||||||
force,
|
force,
|
||||||
max_possible_force,
|
max_possible_force,
|
||||||
..
|
..
|
||||||
}) => (force / max_possible_force).clamp(0.0, 1.0) as f32,
|
}) => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
|
||||||
None => 0.5,
|
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;
|
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
|
||||||
|
|
||||||
@@ -1591,7 +1611,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_erasing = true;
|
is_erasing = true;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
let page = notebook.current_page_mut();
|
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() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -1732,7 +1752,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
} else if is_erasing {
|
} else if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
let page = notebook.current_page_mut();
|
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() {
|
if !removed.is_empty() {
|
||||||
canvas.sync_page(page);
|
canvas.sync_page(page);
|
||||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
@@ -2410,14 +2430,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
UiAction::SetSidebarSide(side) => {
|
UiAction::SetSidebarSide(side) => {
|
||||||
ui_settings.sidebar_side = side;
|
ui_settings.sidebar_side = side;
|
||||||
if let Some(path) = ui_settings_path.as_deref() {
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
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}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.request_redraw();
|
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 => {
|
UiAction::ForceExit => {
|
||||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||||
target.exit();
|
target.exit();
|
||||||
|
|||||||
+149
-3
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::background::PaperTheme;
|
||||||
use crate::storage::{self, StorageError};
|
use crate::storage::{self, StorageError};
|
||||||
|
|
||||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
@@ -18,11 +19,82 @@ pub enum SidebarSide {
|
|||||||
Right,
|
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.
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct UiSettings {
|
pub struct UiSettings {
|
||||||
pub sidebar_side: SidebarSide,
|
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 {
|
impl UiSettings {
|
||||||
@@ -37,7 +109,7 @@ impl UiSettings {
|
|||||||
|
|
||||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
pub fn load_from(path: &Path) -> Self {
|
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| {
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||||
@@ -46,7 +118,9 @@ impl UiSettings {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}),
|
}),
|
||||||
Err(_) => Self::default(),
|
Err(_) => Self::default(),
|
||||||
}
|
};
|
||||||
|
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Speichert die Einstellungen atomar.
|
/// Speichert die Einstellungen atomar.
|
||||||
@@ -69,6 +143,12 @@ mod tests {
|
|||||||
|
|
||||||
let settings = UiSettings {
|
let settings = UiSettings {
|
||||||
sidebar_side: SidebarSide::Right,
|
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();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
@@ -81,4 +161,70 @@ mod tests {
|
|||||||
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
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 {
|
struct CameraUniforms {
|
||||||
transform_scale: vec2<f32>,
|
transform_scale: vec2<f32>,
|
||||||
transform_offset: 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)
|
@group(0) @binding(0)
|
||||||
@@ -32,3 +34,16 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
|||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
return in.color;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+35
-1
@@ -117,7 +117,11 @@ impl StrokeData {
|
|||||||
};
|
};
|
||||||
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
|
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
|
||||||
stroke.shape = self.shape.clone();
|
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.width = width;
|
||||||
stroke.finalized = self.finalized;
|
stroke.finalized = self.finalized;
|
||||||
stroke
|
stroke
|
||||||
@@ -2282,4 +2286,34 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,19 @@
|
|||||||
//! (links oder rechts, umschaltbar):
|
//! (links oder rechts, umschaltbar):
|
||||||
//! - Symbol-Knöpfe mit 40 px Kantenlänge (stift- und fingertauglich), Tooltips mit Kürzeln
|
//! - Symbol-Knöpfe mit 40 px Kantenlänge (stift- und fingertauglich), Tooltips mit Kürzeln
|
||||||
//! - Werkzeuge, Farben des aktiven Werkzeugs, Auswahl-Aktionen, Undo/Redo, Seitennavigation
|
//! - Werkzeuge, Farben des aktiven Werkzeugs, Auswahl-Aktionen, Undo/Redo, Seitennavigation
|
||||||
//! - Selten genutzte Funktionen (PDF, Hintergrund, Zoom, Touch-Modus, Leistenseite,
|
//! - Selten genutzte Funktionen (PDF, Hintergrund, Papierfarbe, Zoom, Touch-Modus,
|
||||||
//! Updates, Speicherstatus) im ☰-Menü
|
//! Druckkurve, Leistenseite, Updates, Speicherstatus) im ☰-Menü
|
||||||
//! - Popover (Stift/Marker/Menü) öffnen sich zur Blattseite hin
|
//! - Popover (Stift/Marker/Menü) öffnen sich zur Blattseite hin
|
||||||
//! - Hit-Testing für Event-Transparenz außerhalb der Leiste und der Popover
|
//! - Hit-Testing für Event-Transparenz außerhalb der Leiste und der Popover
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::autosave::AutosaveStatus;
|
use crate::autosave::AutosaveStatus;
|
||||||
use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH};
|
use crate::background::{adapt_ink_color, PaperTheme};
|
||||||
use crate::settings::SidebarSide;
|
use crate::document::{
|
||||||
|
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
|
};
|
||||||
|
use crate::settings::{PressureCurve, SidebarSide};
|
||||||
use crate::storage::NotebookSummary;
|
use crate::storage::NotebookSummary;
|
||||||
|
|
||||||
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
||||||
@@ -40,6 +43,15 @@ impl ActiveTool {
|
|||||||
pub fn is_drawing(&self) -> bool {
|
pub fn is_drawing(&self) -> bool {
|
||||||
matches!(self, ActiveTool::Pen | ActiveTool::Highlighter)
|
matches!(self, ActiveTool::Pen | ActiveTool::Highlighter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält.
|
||||||
|
pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool {
|
||||||
|
match self {
|
||||||
|
_ if has_shape => StrokeTool::Shape,
|
||||||
|
ActiveTool::Highlighter => StrokeTool::Highlighter,
|
||||||
|
_ => StrokeTool::Pen,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Farbvorgabe: RGBA-Farbe und Bezeichnung.
|
/// Farbvorgabe: RGBA-Farbe und Bezeichnung.
|
||||||
@@ -132,6 +144,13 @@ pub enum UiAction {
|
|||||||
UnlockCorruptedPage(u64),
|
UnlockCorruptedPage(u64),
|
||||||
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
||||||
SetSidebarSide(SidebarSide),
|
SetSidebarSide(SidebarSide),
|
||||||
|
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||||
|
SetPaperTheme {
|
||||||
|
theme: PaperTheme,
|
||||||
|
adapt_ink: bool,
|
||||||
|
},
|
||||||
|
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||||
|
SavePressureCurve(PressureCurve),
|
||||||
// Beenden-Sicherheit bei Fehlschlag
|
// Beenden-Sicherheit bei Fehlschlag
|
||||||
RetryShutdown,
|
RetryShutdown,
|
||||||
ExportRecoveryAndExit,
|
ExportRecoveryAndExit,
|
||||||
@@ -183,6 +202,16 @@ pub struct UiState {
|
|||||||
pub sidebar_side: SidebarSide,
|
pub sidebar_side: SidebarSide,
|
||||||
/// Öffnungszustand des ☰-Menüs
|
/// Öffnungszustand des ☰-Menüs
|
||||||
pub is_more_menu_open: bool,
|
pub is_more_menu_open: bool,
|
||||||
|
/// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste)
|
||||||
|
pub eraser_mode: EraserMode,
|
||||||
|
/// Papierfarbe der Anzeige
|
||||||
|
pub paper_theme: PaperTheme,
|
||||||
|
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||||
|
pub adapt_ink_colors: bool,
|
||||||
|
/// Druckkurve des Stifts
|
||||||
|
pub pressure_curve: PressureCurve,
|
||||||
|
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||||
|
pub last_raw_pressure: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -218,6 +247,11 @@ impl Default for UiState {
|
|||||||
shutdown_error: None,
|
shutdown_error: None,
|
||||||
sidebar_side: SidebarSide::Left,
|
sidebar_side: SidebarSide::Left,
|
||||||
is_more_menu_open: false,
|
is_more_menu_open: false,
|
||||||
|
eraser_mode: EraserMode::All,
|
||||||
|
paper_theme: PaperTheme::White,
|
||||||
|
adapt_ink_colors: true,
|
||||||
|
pressure_curve: PressureCurve::default(),
|
||||||
|
last_raw_pressure: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,14 +740,21 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH +
|
|||||||
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
||||||
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
||||||
|
|
||||||
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format).
|
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der
|
||||||
/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt.
|
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten
|
||||||
pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape> {
|
/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
|
||||||
|
pub fn page_thumbnail_shapes(
|
||||||
|
page: &Page,
|
||||||
|
rect: egui::Rect,
|
||||||
|
paper: PaperTheme,
|
||||||
|
adapt_ink: bool,
|
||||||
|
) -> Vec<egui::Shape> {
|
||||||
let mut shapes = vec![egui::Shape::rect_filled(
|
let mut shapes = vec![egui::Shape::rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(3.0),
|
egui::Rounding::same(3.0),
|
||||||
egui::Color32::WHITE,
|
linear_color32(paper.paper_color()),
|
||||||
)];
|
)];
|
||||||
|
let adapt = adapt_ink && paper.is_dark();
|
||||||
let scale = rect.width() / PAGE_WIDTH;
|
let scale = rect.width() / PAGE_WIDTH;
|
||||||
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
||||||
|
|
||||||
@@ -721,7 +762,12 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape>
|
|||||||
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
||||||
|
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
let mut color = color32_from(stroke.color);
|
let ink = if adapt {
|
||||||
|
adapt_ink_color(stroke.color)
|
||||||
|
} else {
|
||||||
|
stroke.color
|
||||||
|
};
|
||||||
|
let mut color = color32_from(ink);
|
||||||
if stroke.tool == StrokeTool::Highlighter {
|
if stroke.tool == StrokeTool::Highlighter {
|
||||||
color = color.gamma_multiply(0.45);
|
color = color.gamma_multiply(0.45);
|
||||||
}
|
}
|
||||||
@@ -743,9 +789,15 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape>
|
|||||||
shapes
|
shapes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB).
|
||||||
|
fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
||||||
|
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
|
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
|
||||||
fn page_thumbnail(
|
fn page_thumbnail(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
state: &UiState,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
number: usize,
|
number: usize,
|
||||||
is_current: bool,
|
is_current: bool,
|
||||||
@@ -757,7 +809,12 @@ fn page_thumbnail(
|
|||||||
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
||||||
);
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.extend(page_thumbnail_shapes(page, thumb));
|
painter.extend(page_thumbnail_shapes(
|
||||||
|
page,
|
||||||
|
thumb,
|
||||||
|
state.paper_theme,
|
||||||
|
state.adapt_ink_colors,
|
||||||
|
));
|
||||||
if !page.is_loaded {
|
if !page.is_loaded {
|
||||||
painter.text(
|
painter.text(
|
||||||
thumb.center(),
|
thumb.center(),
|
||||||
@@ -790,6 +847,112 @@ fn page_thumbnail(
|
|||||||
response.on_hover_text(format!("Seite {number}"))
|
response.on_hover_text(format!("Seite {number}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||||||
|
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
|
||||||
|
let response = ui.add_sized(
|
||||||
|
[115.0, 28.0],
|
||||||
|
egui::SelectableLabel::new(selected, format!(" {}", theme.label())),
|
||||||
|
);
|
||||||
|
let swatch = egui::Rect::from_center_size(
|
||||||
|
egui::pos2(response.rect.min.x + 14.0, response.rect.center().y),
|
||||||
|
egui::vec2(14.0, 14.0),
|
||||||
|
);
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
swatch,
|
||||||
|
egui::Rounding::same(3.0),
|
||||||
|
linear_color32(theme.paper_color()),
|
||||||
|
);
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
swatch,
|
||||||
|
egui::Rounding::same(3.0),
|
||||||
|
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
|
||||||
|
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
|
||||||
|
/// (Regler losgelassen oder per Tastatur geändert).
|
||||||
|
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
||||||
|
let mut save = false;
|
||||||
|
let mut track = |response: egui::Response| {
|
||||||
|
if response.drag_stopped() || (response.changed() && !response.dragged()) {
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label("Druckkurve des Stifts");
|
||||||
|
let (graph, _) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), egui::Sense::hover());
|
||||||
|
let painter = ui.painter_at(graph);
|
||||||
|
painter.rect_filled(
|
||||||
|
graph,
|
||||||
|
egui::Rounding::same(4.0),
|
||||||
|
egui::Color32::from_black_alpha(90),
|
||||||
|
);
|
||||||
|
let to_screen = |raw: f32, out: f32| {
|
||||||
|
egui::pos2(
|
||||||
|
graph.min.x + 6.0 + raw * (graph.width() - 12.0),
|
||||||
|
graph.max.y - 6.0 - out * (graph.height() - 12.0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.line_segment(
|
||||||
|
[to_screen(0.0, 0.0), to_screen(1.0, 1.0)],
|
||||||
|
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)),
|
||||||
|
);
|
||||||
|
let curve = state.pressure_curve;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=48)
|
||||||
|
.map(|i| {
|
||||||
|
let raw = i as f32 / 48.0;
|
||||||
|
to_screen(raw, curve.apply(raw))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(
|
||||||
|
points,
|
||||||
|
egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(110, 165, 255)),
|
||||||
|
));
|
||||||
|
if let Some(raw) = state.last_raw_pressure {
|
||||||
|
let raw = raw.clamp(0.0, 1.0);
|
||||||
|
painter.circle_filled(
|
||||||
|
to_screen(raw, curve.apply(raw)),
|
||||||
|
4.0,
|
||||||
|
egui::Color32::from_rgb(255, 200, 80),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("weich");
|
||||||
|
ui.spacing_mut().slider_width = (ui.available_width() - 40.0).max(60.0);
|
||||||
|
track(ui.add(
|
||||||
|
egui::Slider::new(&mut state.pressure_curve.exponent, PressureCurve::EXPONENT_RANGE)
|
||||||
|
.logarithmic(true)
|
||||||
|
.show_value(false),
|
||||||
|
).on_hover_text("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien."));
|
||||||
|
ui.label("hart");
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Mindestdruck");
|
||||||
|
ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0);
|
||||||
|
track(ui.add(
|
||||||
|
egui::Slider::new(&mut state.pressure_curve.threshold, PressureCurve::THRESHOLD_RANGE)
|
||||||
|
.custom_formatter(|v, _| format!("{:.0} %", v * 100.0))
|
||||||
|
.custom_parser(|s| s.trim().trim_end_matches('%').trim().parse::<f64>().ok().map(|v| v / 100.0)),
|
||||||
|
).on_hover_text("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden."));
|
||||||
|
});
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
state.pressure_curve != PressureCurve::default(),
|
||||||
|
egui::Button::new("Druckkurve zurücksetzen"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.pressure_curve = PressureCurve::default();
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
save.then_some(state.pressure_curve)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rahmen der Popover neben der Werkzeugleiste.
|
/// Rahmen der Popover neben der Werkzeugleiste.
|
||||||
fn popover_frame() -> egui::Frame {
|
fn popover_frame() -> egui::Frame {
|
||||||
egui::Frame::none()
|
egui::Frame::none()
|
||||||
@@ -1085,7 +1248,7 @@ pub fn render_ui(
|
|||||||
(
|
(
|
||||||
ActiveTool::Eraser,
|
ActiveTool::Eraser,
|
||||||
SidebarIcon::Eraser,
|
SidebarIcon::Eraser,
|
||||||
"Radierer (E oder rechte Maustaste)",
|
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
ActiveTool::Select,
|
ActiveTool::Select,
|
||||||
@@ -1146,10 +1309,44 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let missing = UiState::MAX_PALETTE_LEN.saturating_sub(shown);
|
|
||||||
if missing > 0 {
|
|
||||||
let spacing = ui.spacing().item_spacing.y;
|
let spacing = ui.spacing().item_spacing.y;
|
||||||
ui.add_space(missing as f32 * (swatch + spacing));
|
let mut used = shown as f32 * (swatch + spacing);
|
||||||
|
// Radierer: statt Farben die Radier-Modi im selben Platz
|
||||||
|
if state.active_tool == ActiveTool::Eraser {
|
||||||
|
for (mode, icon, indicator, tooltip) in [
|
||||||
|
(
|
||||||
|
EraserMode::All,
|
||||||
|
SidebarIcon::Text("Alle"),
|
||||||
|
None,
|
||||||
|
"Alle Striche radieren",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EraserMode::HighlighterOnly,
|
||||||
|
SidebarIcon::Glyph("🖊"),
|
||||||
|
Some(state.highlighter_color),
|
||||||
|
"Nur Textmarker radieren – Handschrift darunter bleibt",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if sidebar_button(
|
||||||
|
ui,
|
||||||
|
btn,
|
||||||
|
icon,
|
||||||
|
state.eraser_mode == mode,
|
||||||
|
true,
|
||||||
|
indicator,
|
||||||
|
tooltip,
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.eraser_mode = mode;
|
||||||
|
}
|
||||||
|
used += btn + spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reserved =
|
||||||
|
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
|
||||||
|
if reserved > used {
|
||||||
|
ui.add_space(reserved - used);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Aktionen für die Auswahl ---
|
// --- Aktionen für die Auswahl ---
|
||||||
@@ -1623,6 +1820,10 @@ pub fn render_ui(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bei kleinen Fenstern scrollen statt abschneiden
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height((ctx.screen_rect().height() - 90.0).max(150.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
section(ui, "DOKUMENT");
|
section(ui, "DOKUMENT");
|
||||||
if ui
|
if ui
|
||||||
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)"))
|
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)"))
|
||||||
@@ -1668,6 +1869,48 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
section(ui, "PAPIERFARBE (NUR ANZEIGE)");
|
||||||
|
let mut paper_changed = false;
|
||||||
|
egui::Grid::new("more_menu_paper")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([6.0, 6.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (i, theme) in PaperTheme::ALL.into_iter().enumerate() {
|
||||||
|
if paper_theme_button(ui, theme, state.paper_theme == theme)
|
||||||
|
.clicked()
|
||||||
|
&& state.paper_theme != theme
|
||||||
|
{
|
||||||
|
state.paper_theme = theme;
|
||||||
|
paper_changed = true;
|
||||||
|
}
|
||||||
|
if i % 2 == 1 {
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
state.paper_theme.is_dark(),
|
||||||
|
egui::Checkbox::new(
|
||||||
|
&mut state.adapt_ink_colors,
|
||||||
|
"Dunkle Stiftfarben aufhellen",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_hover_text(
|
||||||
|
"Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \
|
||||||
|
als PDF exportiert werden immer die Originalfarben.",
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
paper_changed = true;
|
||||||
|
}
|
||||||
|
if paper_changed {
|
||||||
|
actions.push(UiAction::SetPaperTheme {
|
||||||
|
theme: state.paper_theme,
|
||||||
|
adapt_ink: state.adapt_ink_colors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
section(ui, "ZOOM");
|
section(ui, "ZOOM");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
@@ -1702,6 +1945,9 @@ pub fn render_ui(
|
|||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
"Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.",
|
"Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.",
|
||||||
);
|
);
|
||||||
|
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||||
|
actions.push(UiAction::SavePressureCurve(curve));
|
||||||
|
}
|
||||||
|
|
||||||
section(ui, "WERKZEUGLEISTE");
|
section(ui, "WERKZEUGLEISTE");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -1757,6 +2003,7 @@ pub fn render_ui(
|
|||||||
state.close_overlays();
|
state.close_overlays();
|
||||||
state.is_update_dialog_open = true;
|
state.is_update_dialog_open = true;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.response
|
.response
|
||||||
.rect
|
.rect
|
||||||
@@ -1796,7 +2043,13 @@ pub fn render_ui(
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for (idx, page) in notebook.pages.iter().enumerate() {
|
for (idx, page) in notebook.pages.iter().enumerate() {
|
||||||
let is_current = idx == notebook.current_page_index;
|
let is_current = idx == notebook.current_page_index;
|
||||||
if page_thumbnail(ui, page, idx + 1, is_current)
|
if page_thumbnail(
|
||||||
|
ui,
|
||||||
|
state,
|
||||||
|
page,
|
||||||
|
idx + 1,
|
||||||
|
is_current,
|
||||||
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(UiAction::GoToPage(idx));
|
actions.push(UiAction::GoToPage(idx));
|
||||||
@@ -2917,6 +3170,19 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stroke_tool_follows_active_tool() {
|
||||||
|
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);
|
||||||
|
assert_eq!(
|
||||||
|
ActiveTool::Highlighter.stroke_tool(false),
|
||||||
|
StrokeTool::Highlighter
|
||||||
|
);
|
||||||
|
assert_eq!(ActiveTool::Shape.stroke_tool(false), StrokeTool::Pen);
|
||||||
|
// Eingerastete Form (Formwerkzeug oder Halten am Strichende)
|
||||||
|
assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape);
|
||||||
|
assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_all_ui_texts_are_renderable() {
|
fn test_all_ui_texts_are_renderable() {
|
||||||
// Zeichen ohne Glyphe in den eingebauten egui-Schriften erscheinen als leere Kästchen
|
// Zeichen ohne Glyphe in den eingebauten egui-Schriften erscheinen als leere Kästchen
|
||||||
@@ -3130,11 +3396,14 @@ mod tests {
|
|||||||
|
|
||||||
// Leere Seite: nur das weiße Blatt
|
// Leere Seite: nur das weiße Blatt
|
||||||
let empty = Page::new(1, PageBackground::Blank);
|
let empty = Page::new(1, PageBackground::Blank);
|
||||||
assert_eq!(page_thumbnail_shapes(&empty, rect).len(), 1);
|
assert_eq!(
|
||||||
|
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true).len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
// Striche liegen innerhalb der Kachel
|
// Striche liegen innerhalb der Kachel
|
||||||
let page = page_with_strokes(3, 20);
|
let page = page_with_strokes(3, 20);
|
||||||
let shapes = page_thumbnail_shapes(&page, rect);
|
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true);
|
||||||
assert_eq!(shapes.len(), 1 + 3);
|
assert_eq!(shapes.len(), 1 + 3);
|
||||||
let pts = shape_points(&shapes);
|
let pts = shape_points(&shapes);
|
||||||
assert!(!pts.is_empty());
|
assert!(!pts.is_empty());
|
||||||
@@ -3145,7 +3414,7 @@ mod tests {
|
|||||||
|
|
||||||
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
|
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
|
||||||
let full = page_with_strokes(100, 1000);
|
let full = page_with_strokes(100, 1000);
|
||||||
let pts = shape_points(&page_thumbnail_shapes(&full, rect));
|
let pts = shape_points(&page_thumbnail_shapes(&full, rect, PaperTheme::White, true));
|
||||||
assert!(
|
assert!(
|
||||||
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
|
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
|
||||||
"{} Punkte statt höchstens {}",
|
"{} Punkte statt höchstens {}",
|
||||||
|
|||||||
Reference in New Issue
Block a user