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:
2026-10-07 15:04:59 +02:00
co-authored by Claude Opus 5.5
parent 616fc57666
commit 3e2b627d6a
10 changed files with 904 additions and 97 deletions
+289 -20
View File
@@ -4,16 +4,19 @@
//! (links oder rechts, umschaltbar):
//! - 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
//! - Selten genutzte Funktionen (PDF, Hintergrund, Zoom, Touch-Modus, Leistenseite,
//! Updates, Speicherstatus) im ☰-Menü
//! - Selten genutzte Funktionen (PDF, Hintergrund, Papierfarbe, Zoom, Touch-Modus,
//! Druckkurve, Leistenseite, Updates, Speicherstatus) im ☰-Menü
//! - Popover (Stift/Marker/Menü) öffnen sich zur Blattseite hin
//! - Hit-Testing für Event-Transparenz außerhalb der Leiste und der Popover
use std::time::Instant;
use crate::autosave::AutosaveStatus;
use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH};
use crate::settings::SidebarSide;
use crate::background::{adapt_ink_color, PaperTheme};
use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::settings::{PressureCurve, SidebarSide};
use crate::storage::NotebookSummary;
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
@@ -40,6 +43,15 @@ impl ActiveTool {
pub fn is_drawing(&self) -> bool {
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.
@@ -132,6 +144,13 @@ pub enum UiAction {
UnlockCorruptedPage(u64),
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
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
RetryShutdown,
ExportRecoveryAndExit,
@@ -183,6 +202,16 @@ pub struct UiState {
pub sidebar_side: SidebarSide,
/// Öffnungszustand des ☰-Menüs
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 {
@@ -218,6 +247,11 @@ impl Default for UiState {
shutdown_error: None,
sidebar_side: SidebarSide::Left,
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).
const THUMBNAIL_POINT_BUDGET: usize = 1500;
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format).
/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt.
pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape> {
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten
/// 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(
rect,
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 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);
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 {
color = color.gamma_multiply(0.45);
}
@@ -743,9 +789,15 @@ pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape>
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.
fn page_thumbnail(
ui: &mut egui::Ui,
state: &UiState,
page: &Page,
number: usize,
is_current: bool,
@@ -757,7 +809,12 @@ fn page_thumbnail(
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
);
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 {
painter.text(
thumb.center(),
@@ -790,6 +847,112 @@ fn page_thumbnail(
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.
fn popover_frame() -> egui::Frame {
egui::Frame::none()
@@ -1085,7 +1248,7 @@ pub fn render_ui(
(
ActiveTool::Eraser,
SidebarIcon::Eraser,
"Radierer (E oder rechte Maustaste)",
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
),
(
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;
ui.add_space(missing as f32 * (swatch + spacing));
let spacing = ui.spacing().item_spacing.y;
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 ---
@@ -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");
if ui
.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");
ui.horizontal(|ui| {
if ui
@@ -1702,6 +1945,9 @@ pub fn render_ui(
.on_hover_text(
"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");
ui.horizontal(|ui| {
@@ -1757,6 +2003,7 @@ pub fn render_ui(
state.close_overlays();
state.is_update_dialog_open = true;
}
});
})
.response
.rect
@@ -1796,8 +2043,14 @@ pub fn render_ui(
.show(ui, |ui| {
for (idx, page) in notebook.pages.iter().enumerate() {
let is_current = idx == notebook.current_page_index;
if page_thumbnail(ui, page, idx + 1, is_current)
.clicked()
if page_thumbnail(
ui,
state,
page,
idx + 1,
is_current,
)
.clicked()
{
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
@@ -2917,6 +3170,19 @@ mod tests {
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]
fn test_all_ui_texts_are_renderable() {
// 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
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
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);
let pts = shape_points(&shapes);
assert!(!pts.is_empty());
@@ -3145,7 +3414,7 @@ mod tests {
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
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!(
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
"{} Punkte statt höchstens {}",