Files
ink-notes/src/ui.rs
T
haraldandClaude Opus 5.5 3c793fe659 feat: Endlose Fläche als Seitenanordnung (Issue #30, Schritt 2)
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe,
Treffertests, Undo und Speichern bleiben dadurch unverändert. Die
Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und
Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü,
umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im
Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus.

Der Export enthält nur den beschriebenen Bereich mit 10 mm Rand, auf
das Musterraster gelegt; Bilder einer Auswahl rastern nur noch den
Ausschnitt statt der ganzen Seite. Formatversion 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:45 +02:00

5955 lines
252 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Immediate-Mode UI mit `egui` für ink-notes.
//!
//! Stellt eine schmale, senkrechte Werkzeugleiste bereit, die seitlich angedockt ist
//! (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, 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::background::{adapt_ink_color, PaperTheme, PatternColor};
use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit};
use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
};
use crate::storage::{NotebookLayout, NotebookSummary};
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
use crate::{t, tn};
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActiveTool {
Pen,
Highlighter,
Eraser,
Shape,
Select,
/// Textfelder schreiben und bearbeiten
Text,
/// Platz schaffen: Inhalt ab einer Höhe nach unten oder oben schieben
Space,
}
impl ActiveTool {
pub fn name(&self) -> &'static str {
match self {
ActiveTool::Pen => t!("Stift (Pen)"),
ActiveTool::Highlighter => t!("Textmarker (Highlighter)"),
ActiveTool::Eraser => t!("Radierer (Eraser)"),
ActiveTool::Shape => t!("Formen (Shape)"),
ActiveTool::Select => t!("Auswahl / Lasso (Select)"),
ActiveTool::Text => t!("Text (T)"),
ActiveTool::Space => t!("Platz schaffen"),
}
}
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.
pub type ColorPreset = ([f32; 4], &'static str);
/// Schnellauswahlfarben für den normalen Stift laut Meilenstein 8:
/// - Notiz-Schwarz: [0.12, 0.12, 0.14, 1.0]
/// - Universitäts-Blau: [0.15, 0.35, 0.85, 1.0]
/// - Korrektur-Rot: [0.85, 0.20, 0.20, 1.0]
/// - Tafel-Grün: [0.18, 0.65, 0.35, 1.0]
/// - Graphit-Grau: [0.45, 0.45, 0.48, 1.0]
pub const PEN_COLOR_PRESETS: [([f32; 4], &str); 5] = [
([0.12, 0.12, 0.14, 1.0], tn!("Notiz-Schwarz")),
([0.15, 0.35, 0.85, 1.0], tn!("Universitäts-Blau")),
([0.85, 0.20, 0.20, 1.0], tn!("Korrektur-Rot")),
([0.18, 0.65, 0.35, 1.0], tn!("Tafel-Grün")),
([0.45, 0.45, 0.48, 1.0], tn!("Graphit-Grau")),
];
/// Schnellauswahlfarben für den Textmarker (halbtransparent, Alpha 0.35):
/// - Neon-Gelb, Hellgrün, Pastell-Cyan, Rosa
pub const HIGHLIGHTER_COLOR_PRESETS: [([f32; 4], &str); 4] = [
([1.0, 0.95, 0.1, 0.35], tn!("Neon-Gelb")),
([0.2, 0.9, 0.3, 0.35], tn!("Hellgrün")),
([0.25, 0.7, 1.0, 0.35], tn!("Pastell-Cyan")),
([1.0, 0.3, 0.65, 0.35], tn!("Rosa")),
];
/// Dicken-Presets für feines Inking und präzise Notizen:
/// - Extra-Fein: 0.3 mm (für kleine Formeln / Indizes)
/// - Fein: 0.5 mm (Standard-Fließtext)
/// - Normal: 0.8 mm (Hervorhebungen)
/// - Fett: 1.2 mm (Überschriften)
pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
(0.3, "0.3 mm"),
(0.5, "0.5 mm"),
(0.8, "0.8 mm"),
(1.2, "1.2 mm"),
];
/// Dicken-Presets für den Textmarker:
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageView {
/// Bildschirmposition des Seitenursprungs (oben links)
pub origin: egui::Pos2,
/// Bildschirmpunkte je Seiteneinheit
pub scale: f32,
}
impl PageView {
pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 {
self.origin + egui::vec2(p.x, p.y) * self.scale
}
pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 {
let d = (p - self.origin) / self.scale.max(1e-6);
glam::Vec2::new(d.x, d.y)
}
}
/// Erkennt langes Drücken mit einem Finger: still liegen (höchstens [`Self::SLOP`] logische
/// Punkte Bewegung) für [`Self::DURATION`]. Positionen in physischen Pixeln.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct LongPress {
pending: Option<(u64, Instant, (f32, f32))>,
}
impl LongPress {
pub const DURATION: std::time::Duration = std::time::Duration::from_millis(500);
pub const SLOP: f32 = 10.0;
/// Finger `id` hat bei `pos` aufgesetzt.
pub fn start(&mut self, id: u64, pos: (f32, f32), now: Instant) {
self.pending = Some((id, now, pos));
}
/// Bewegung: zu weit (oder ein anderer Finger) bricht ab.
pub fn moved(&mut self, id: u64, pos: (f32, f32), scale: f32) {
if let Some((pending, _, start)) = self.pending {
if pending != id || (pos.0 - start.0).hypot(pos.1 - start.1) > Self::SLOP * scale {
self.pending = None;
}
}
}
/// Finger abgehoben, zweiter Finger oder sonstiger Abbruch.
pub fn cancel(&mut self) {
self.pending = None;
}
/// Wann ausgelöst wird (zum Aufwecken der Ereignisschleife).
pub fn deadline(&self) -> Option<Instant> {
self.pending.map(|(_, started, _)| started + Self::DURATION)
}
/// Ist die Zeit um, wird einmal die Stelle des Drückens geliefert.
pub fn fire(&mut self, now: Instant) -> Option<(f32, f32)> {
let (_, started, pos) = self.pending?;
(now >= started + Self::DURATION).then(|| {
self.pending = None;
pos
})
}
}
/// Was im Dokumenten-Manager gerade verschoben wird.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveSubject {
Notebook(String),
/// Pfad des Ordners
Folder(String),
}
/// Laufendes Platzschaffen (für die Vorschau): Seite, Höhe auf der Seite, bisherige
/// Verschiebung und wie weit es nach oben höchstens geht (Seiteneinheiten).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceDrag {
pub page_index: usize,
pub y: f32,
pub dy: f32,
pub up_limit: f32,
}
impl SpaceDrag {
/// Verschiebung nach oben begrenzt auf den leeren Platz.
pub fn clamped_dy(&self) -> f32 {
self.dy.max(-self.up_limit)
}
}
/// Kontextmenü nach langem Drücken auf das Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenu {
/// Bildschirmposition (logische Punkte) und Stelle auf der aktuellen Seite
pub screen: egui::Pos2,
pub page: glam::Vec2,
}
/// Laufende Bedienung des Lineals.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RulerDrag {
/// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt
Move {
grab_offset: glam::Vec2,
},
Rotate,
}
/// Format für den Export der aktuellen Seite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageExportKind {
Png,
PngTransparent,
Svg,
}
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
#[derive(Debug, Clone, PartialEq)]
pub enum UiAction {
SetTool(ActiveTool),
SetColor([f32; 4]),
SetStrokeWidthMm(f32),
Undo,
Redo,
PreviousPage,
NextPage,
AddPage,
/// Aktuelle Seite löschen (nach Rückfrage in der Oberfläche)
DeleteCurrentPage,
/// Zuletzt gelöschte Seite wiederherstellen
RestoreDeletedPage,
GoToPage(usize),
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
LoadAllPages,
SetBackground(PageBackground),
ExportPdf,
ZoomIn,
ZoomOut,
ResetZoom,
DeleteSelected,
CopySelected,
PasteSelected,
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
PasteAt(glam::Vec2),
RotateSelected,
/// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben
ToggleAspectLock,
// Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String),
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen
CreateNotebook {
title: String,
folder: String,
layout: NotebookLayout,
},
/// Ordnerverwaltung (siehe `library.rs`)
CreateFolder {
parent: String,
name: String,
},
RenameFolder {
path: String,
new_name: String,
},
/// Ordner löschen; Inhalt rückt eine Ebene nach oben
DeleteFolder(String),
MoveNotebook {
id: String,
folder: String,
},
/// Ordner samt Inhalt nach `new_parent` verschieben
MoveFolder {
path: String,
new_parent: String,
},
RenameNotebook {
id: String,
new_title: String,
},
DeleteNotebook(String),
UndoDeleteNotebook,
RestoreNotebookBackup(String),
OpenNotebookFolder(String),
RefreshNotebooks,
// Gitea Release & Updater
CheckForUpdates,
DownloadUpdateAsset(crate::updater::GiteaAsset),
InstallOrOpenUpdate(std::path::PathBuf),
OpenUrlInBrowser(String),
SaveUpdaterConfig {
server_url: String,
auth_token: Option<String>,
},
// Datenintegrität / Quarantäne
UnlockCorruptedPage(u64),
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
SetSidebarSide(SidebarSide),
/// Sprache der Oberfläche; `None` = Sprache des Systems
SetLanguage(Option<crate::i18n::Language>),
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
SetPaperTheme {
theme: PaperTheme,
adapt_ink: bool,
},
/// Belegung der Stifttasten speichern (gilt sofort über `UiState::stylus_buttons`)
SaveStylusButtons,
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve),
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
SetNotebookLayout(NotebookLayout),
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
SetPatternColor(PatternColor),
/// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf),
/// PDF als neues Notizbuch importieren
ImportPdf(std::path::PathBuf),
/// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen)
SetPageTitle {
page_id: u64,
title: String,
},
// Beenden-Sicherheit bei Fehlschlag
RetryShutdown,
ExportRecoveryAndExit,
ForceExit,
}
/// Zustand der grafischen Benutzeroberfläche.
#[derive(Debug, Clone)]
pub struct UiState {
pub active_tool: ActiveTool,
pub pen_color: [f32; 4],
pub highlighter_color: [f32; 4],
pub pen_stroke_width_mm: f32,
pub highlighter_stroke_width_mm: f32,
pub export_message: Option<(String, Instant)>,
pub show_page_jump_popup: bool,
/// Aktueller Zoomfaktor zur Anzeige in der Toolbar
pub current_zoom: f32,
/// Bildschirm-Rechteck der schwebenden Toolbar für pixelgenaues Hit-Testing
pub toolbar_rect: Option<egui::Rect>,
/// Öffnungszustand des Einstellungs-Popovers für den Stift
pub is_pen_menu_open: bool,
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
pub is_highlighter_menu_open: bool,
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
pub popover_rect: Option<egui::Rect>,
/// Dokumenten-Manager Zustand
pub is_document_menu_open: bool,
pub current_notebook_id: String,
pub document_title: String,
pub available_notebooks: Vec<NotebookSummary>,
pub new_notebook_title_input: String,
/// Anordnung für das nächste neue Notizbuch
pub new_notebook_layout: NotebookLayout,
pub renaming_notebook_id: Option<String>,
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
pub library: crate::library::Library,
pub notebook_folder: String,
/// Ordner, der gerade umbenannt wird, bzw. dessen Löschen bestätigt werden soll
pub renaming_folder: Option<String>,
pub confirm_delete_folder: Option<String>,
/// Notizbuch oder Ordner, für das gerade ein Zielordner gewählt wird
pub moving: Option<MoveSubject>,
pub renaming_title_input: String,
pub document_modal_rect: Option<egui::Rect>,
/// Gitea Release- & Update-Manager
pub is_update_dialog_open: bool,
pub updater_server_url_input: String,
pub updater_token_input: String,
pub show_updater_config: bool,
pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool,
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
/// `pen_stroke_width_mm` gehören immer zum gewählten Stift
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
/// Eigene Farben des Textmarkers in der Werkzeugleiste und die gewählte;
/// `highlighter_color` gehört immer zur gewählten
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
/// Eigene Farben des Text-Werkzeugs und die zuletzt angetippte (ein geöffnetes Textfeld
/// kann eine andere Farbe haben)
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
/// Popover „Textfarbe“ ist offen
pub is_text_color_menu_open: bool,
/// Farbwähler in den Farb-Popovern aufgeklappt
pub show_color_picker: bool,
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
pub context_menu: Option<ContextMenu>,
/// Laufendes Platzschaffen (Vorschau)
pub space_drag: Option<SpaceDrag>,
/// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben
pub stylus_buttons: Vec<crate::settings::StylusButton>,
pub pressed_stylus_button: Option<usize>,
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
pub restorable_page: Option<usize>,
/// Seitenverhältnis-Schloss der Auswahl (`None`: nichts ausgewählt)
pub selection_aspect_lock: Option<bool>,
/// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>,
/// Fensterseite der Werkzeugleiste
pub sidebar_side: SidebarSide,
/// Gewählte Sprache der Oberfläche; `None` = Sprache des Systems
pub language: Option<crate::i18n::Language>,
/// Ö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,
/// Farbe von Karos, Linien und Punkten
pub pattern_color: PatternColor,
/// Seiten hängen untereinander und werden durchgehend gescrollt
pub continuous_scroll: bool,
/// Offenes Notizbuch ist eine endlose Fläche (eine Seite ohne Rand, Issue #30)
pub endless: bool,
/// Wechsel zur bzw. von der endlosen Fläche ist gerade möglich (eine Seite ohne PDF bzw.
/// Inhalt im Startausschnitt)
pub endless_switch_possible: bool,
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
pub text_color: [f32; 4],
pub text_size_pt: f32,
pub text_font: TextFont,
/// Laufende Bearbeitung eines Textfelds
pub text_edit: Option<TextEditing>,
/// Druckkurve des Stifts
pub pressure_curve: PressureCurve,
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
pub last_raw_pressure: Option<f32>,
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
pub file_picker: Option<FilePicker>,
/// Eingeblendetes Lineal
pub ruler: Option<Ruler>,
pub ruler_drag: Option<RulerDrag>,
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
pub page_view: Option<PageView>,
/// Fenstergröße in logischen Punkten (vom letzten Bild)
pub screen_size: egui::Vec2,
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
pub page_title_input: String,
pub page_title_for: Option<u64>,
/// Seitenübersicht als Liste mit Titeln (Inhaltsverzeichnis) statt Kacheln
pub page_picker_list: bool,
/// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene
/// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus
pub queued_actions: Vec<UiAction>,
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
pub last_picker_dir: Option<std::path::PathBuf>,
}
impl Default for UiState {
fn default() -> Self {
Self {
active_tool: ActiveTool::Pen,
pen_color: PEN_COLOR_PRESETS[0].0,
highlighter_color: HIGHLIGHTER_COLOR_PRESETS[0].0,
pen_stroke_width_mm: 0.5, // Standard: 0.5 mm (feine Handschrift)
highlighter_stroke_width_mm: 6.0,
export_message: None,
show_page_jump_popup: false,
current_zoom: 1.0,
toolbar_rect: None,
is_pen_menu_open: false,
is_highlighter_menu_open: false,
popover_rect: None,
is_document_menu_open: false,
current_notebook_id: "default".to_string(),
document_title: t!("Standard-Notizbuch").to_string(),
available_notebooks: Vec::new(),
new_notebook_title_input: String::new(),
new_notebook_layout: NotebookLayout::Paged,
renaming_notebook_id: None,
library: crate::library::Library::default(),
notebook_folder: String::new(),
renaming_folder: None,
confirm_delete_folder: None,
moving: None,
renaming_title_input: String::new(),
document_modal_rect: None,
is_update_dialog_open: false,
updater_server_url_input: "https://gitea.pansi.eu".to_string(),
updater_token_input: String::new(),
show_updater_config: false,
update_modal_rect: None,
touch_draw_enabled: false,
last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
is_text_color_menu_open: false,
show_color_picker: false,
context_menu: None,
space_drag: None,
stylus_buttons: Vec::new(),
pressed_stylus_button: None,
confirm_delete_page: false,
restorable_page: None,
selection_aspect_lock: None,
shutdown_error: None,
sidebar_side: SidebarSide::Left,
language: None,
is_more_menu_open: false,
eraser_mode: EraserMode::All,
paper_theme: PaperTheme::White,
adapt_ink_colors: true,
pattern_color: PatternColor::Light,
continuous_scroll: true,
endless: false,
endless_switch_possible: true,
text_color: PEN_COLOR_PRESETS[0].0,
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
text_font: TextFont::Sans,
text_edit: None,
pressure_curve: PressureCurve::default(),
last_raw_pressure: None,
file_picker: None,
queued_actions: Vec::new(),
ruler: None,
ruler_drag: None,
page_view: None,
screen_size: egui::Vec2::ZERO,
page_title_input: String::new(),
page_title_for: None,
page_picker_list: false,
last_picker_dir: None,
}
}
}
impl UiState {
/// Radius der abgerundeten Ecken der Werkzeugleiste.
pub const TOOLBAR_ROUNDING: f32 = 14.0;
/// Kantenlänge der Symbol-Knöpfe (stift- und fingertauglich).
pub const SIDEBAR_BUTTON: f32 = 40.0;
/// Abstand der Werkzeugleiste zum Fensterrand.
pub const SIDEBAR_MARGIN: f32 = 10.0;
/// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit
/// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) {
self.confirm_delete_page = false;
self.context_menu = None;
self.is_text_color_menu_open = false;
self.is_pen_menu_open = false;
self.is_highlighter_menu_open = false;
self.is_more_menu_open = false;
self.show_page_jump_popup = false;
self.is_document_menu_open = false;
self.is_update_dialog_open = false;
self.file_picker = None;
}
/// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus.
pub fn toggle_ruler(&mut self) {
self.ruler_drag = None;
self.ruler = match self.ruler {
Some(_) => None,
None => {
let center = self
.page_view
.map(|v| {
let screen = self.visible_canvas_center();
v.to_page(screen)
})
.unwrap_or(glam::Vec2::new(
crate::document::PAGE_WIDTH / 2.0,
crate::document::PAGE_HEIGHT / 2.0,
));
Some(Ruler::new(center))
}
};
}
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
fn visible_canvas_center(&self) -> egui::Pos2 {
(self.screen_size / 2.0).to_pos2()
+ egui::vec2(
match self.sidebar_side {
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
SidebarSide::Right => -self.sidebar_reserved_width() / 2.0,
},
0.0,
)
}
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
pub fn open_image_picker(&mut self) {
self.open_file_picker(PickerKind::Image);
}
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
pub fn open_pdf_picker(&mut self) {
self.open_file_picker(PickerKind::Pdf);
}
fn open_file_picker(&mut self, kind: PickerKind) {
let dir = self.last_picker_dir.clone();
self.close_overlays();
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
}
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
pub const MAX_PALETTE_LEN: usize = 5;
/// Durchmesser der Farbtupfer passend zur Knopfgröße.
pub fn sidebar_swatch_size(button_size: f32) -> f32 {
button_size - 10.0
}
/// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen
/// des aktiven Werkzeugs in `max_height` passt. `continuous`: durchgehende Ansicht (ohne
/// Blätterpfeile).
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 {
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
// Notizbücher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln
// blättern: dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
let page_buttons = if continuous { 2 } else { 4 };
let buttons = 11 + page_buttons + if select { 4 } else { 0 };
let dividers = 5 + usize::from(select);
let spacing = 3.0;
let needed = |b: f32| {
buttons as f32 * (b + spacing)
+ Self::MAX_PALETTE_LEN as f32 * (Self::sidebar_swatch_size(b) + spacing)
+ dividers as f32 * (7.0 + spacing)
+ 12.0
};
[Self::SIDEBAR_BUTTON, 36.0, 32.0, 30.0, 28.0]
.into_iter()
.find(|&b| needed(b) <= max_height)
.unwrap_or(28.0)
}
/// Von der Werkzeugleiste belegte Breite (logische Punkte, inkl. Randabstand),
/// damit die Seite daneben zentriert werden kann.
pub fn sidebar_reserved_width(&self) -> f32 {
self.toolbar_rect
.map(|r| r.width() + 2.0 * Self::SIDEBAR_MARGIN)
.unwrap_or(0.0)
}
pub fn new() -> Self {
Self::default()
}
/// Wählt Stift `index`: Farbe und Stärke kommen aus seiner Vorlage.
pub fn select_pen(&mut self, index: usize) {
self.active_pen = index.min(PEN_PRESET_COUNT - 1);
let preset = self.pen_presets[self.active_pen];
self.pen_color = preset.color;
self.pen_stroke_width_mm = preset.width_mm;
}
/// Wählt die eigene Textmarker-Farbe `index`.
pub fn select_highlighter_color(&mut self, index: usize) {
self.active_highlighter = index.min(self.highlighter_colors.len() - 1);
self.highlighter_color = self.highlighter_colors[self.active_highlighter];
}
/// Setzt die Textfarbe (auch für ein gerade bearbeitetes Textfeld).
pub fn set_text_color(&mut self, color: [f32; 4]) {
self.text_color = color;
if let Some(edit) = &mut self.text_edit {
edit.color = color;
edit.focus = true;
}
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts
/// und die Textmarker-Farbe in die gewählte eigene Farbe; `true`, wenn sich etwas
/// geändert hat (dann speichern).
pub fn sync_tool_colors(&mut self) -> bool {
let index = self
.active_highlighter
.min(self.highlighter_colors.len() - 1);
let highlighter_changed = self.highlighter_colors[index] != self.highlighter_color;
self.highlighter_colors[index] = self.highlighter_color;
self.sync_pen_preset() | highlighter_changed
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts;
/// `true`, wenn sie sich geändert hat (dann speichern).
fn sync_pen_preset(&mut self) -> bool {
let current = PenPreset {
color: self.pen_color,
width_mm: self.pen_stroke_width_mm,
};
let preset = &mut self.pen_presets[self.active_pen.min(PEN_PRESET_COUNT - 1)];
if *preset == current {
return false;
}
*preset = current;
true
}
/// Gibt die aktuelle Farbe des gewählten Werkzeugs zurück.
pub fn current_color(&self) -> [f32; 4] {
match self.active_tool {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
ActiveTool::Highlighter => self.highlighter_color,
ActiveTool::Text => self.text_color,
ActiveTool::Eraser | ActiveTool::Space => [0.0, 0.0, 0.0, 0.0],
}
}
/// Gibt die aktuelle Strichbreite in mm zurück.
pub fn current_stroke_width_mm(&self) -> f32 {
match self.active_tool {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => {
self.pen_stroke_width_mm
}
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
ActiveTool::Eraser | ActiveTool::Space => 3.0,
}
}
/// Zeigt eine temporäre Erfolgs- oder Statusmeldung im UI an.
pub fn set_export_message(&mut self, msg: impl Into<String>) {
self.export_message = Some((msg.into(), Instant::now()));
}
/// Exaktes Hit-Testing: Prüft, ob ein Bildschirmpunkt (px, py) innerhalb der
/// abgerundeten Pill-Toolbar liegt (unter Berücksichtigung der 4 Eckradien).
pub fn is_point_inside_toolbar(&self, px: f32, py: f32) -> bool {
let Some(rect) = self.toolbar_rect else {
return false;
};
if px < rect.min.x || px > rect.max.x || py < rect.min.y || py > rect.max.y {
return false;
}
let r = Self::TOOLBAR_ROUNDING;
// 1. Oben-links
if px < rect.min.x + r && py < rect.min.y + r {
let dx = px - (rect.min.x + r);
let dy = py - (rect.min.y + r);
return dx * dx + dy * dy <= r * r;
}
// 2. Oben-rechts
if px > rect.max.x - r && py < rect.min.y + r {
let dx = px - (rect.max.x - r);
let dy = py - (rect.min.y + r);
return dx * dx + dy * dy <= r * r;
}
// 3. Unten-links
if px < rect.min.x + r && py > rect.max.y - r {
let dx = px - (rect.min.x + r);
let dy = py - (rect.max.y - r);
return dx * dx + dy * dy <= r * r;
}
// 4. Unten-rechts
if px > rect.max.x - r && py > rect.max.y - r {
let dx = px - (rect.max.x - r);
let dy = py - (rect.max.y - r);
return dx * dx + dy * dy <= r * r;
}
true
}
/// Prüft, ob ein Bildschirmpunkt (px, py) innerhalb der UI liegt:
/// - Entweder innerhalb der abgerundeten Pill-Toolbar
/// - ODER innerhalb des geöffneten Einstellungs-Popovers
/// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center)
pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool {
// Offenes Kontextmenü: ein Tippen daneben schließt es nur
if self.context_menu.is_some() || self.is_point_inside_toolbar(px, py) {
return true;
}
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
if self.ruler_drag.is_some() {
return true;
}
if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) {
if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() {
return true;
}
}
if let Some(pop_rect) = self.popover_rect {
if pop_rect.contains(egui::pos2(px, py)) {
return true;
}
}
if self.is_document_menu_open {
if let Some(doc_rect) = self.document_modal_rect {
if doc_rect.contains(egui::pos2(px, py)) {
return true;
}
}
}
if self.is_update_dialog_open {
if let Some(upd_rect) = self.update_modal_rect {
if upd_rect.contains(egui::pos2(px, py)) {
return true;
}
}
}
false
}
}
/// Dicken-Preset-Button mit visueller Linien-Vorschau in Originalfarbe und Proportion.
fn stroke_preview_button(
ui: &mut egui::Ui,
is_active: bool,
stroke_width_mm: f32,
label_text: &str,
color: [f32; 4],
) -> egui::Response {
let size = egui::vec2(66.0, 40.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let (bg, border) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200),
),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14),
),
)
};
ui.painter()
.rect_filled(rect, egui::Rounding::same(6.0), bg);
ui.painter()
.rect_stroke(rect, egui::Rounding::same(6.0), border);
// Horizontale Vorschau-Linie zeichnen
let line_thickness = (stroke_width_mm * 2.5).clamp(1.0, 9.0);
let start_x = rect.min.x + 8.0;
let end_x = rect.max.x - 8.0;
let y = rect.center().y - 4.5;
let line_color = egui::Color32::from_rgba_unmultiplied(
(color[0] * 255.0) as u8,
(color[1] * 255.0) as u8,
(color[2] * 255.0) as u8,
((color[3] * 255.0) as u8).max(180),
);
ui.painter().line_segment(
[egui::pos2(start_x, y), egui::pos2(end_x, y)],
egui::Stroke::new(line_thickness, line_color),
);
// Text unterhalb der Linie (z. B. "2.0 mm")
let text_pos = egui::pos2(rect.center().x, rect.max.y - 7.5);
ui.painter().text(
text_pos,
egui::Align2::CENTER_CENTER,
label_text,
egui::FontId::proportional(10.0),
if is_active {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(190)
},
);
}
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
}
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
#[derive(Clone, Copy)]
enum SidebarIcon<'a> {
/// Zeichen aus den eingebauten egui-Schriften
Glyph(&'a str),
/// Kurzer Text (z. B. Seitenzahl) in kleinerer Schrift
Text(&'a str),
/// Selbst gezeichneter Radiergummi (kein passendes Zeichen in den egui-Schriften)
Eraser,
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
Lasso,
/// Selbst gezeichnetes Lineal mit Skala
Ruler,
}
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
fn sidebar_button(
ui: &mut egui::Ui,
size: f32,
icon: SidebarIcon,
is_active: bool,
enabled: bool,
indicator: Option<[f32; 4]>,
tooltip: &str,
) -> egui::Response {
let size = egui::vec2(size, size);
let sense = if enabled {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let (rect, response) = ui.allocate_exact_size(size, sense);
if ui.is_rect_visible(rect) {
let painter = ui.painter();
let bg = if is_active {
egui::Color32::from_rgba_unmultiplied(45, 80, 145, 230)
} else if enabled && response.hovered() {
egui::Color32::from_white_alpha(18)
} else {
egui::Color32::TRANSPARENT
};
painter.rect_filled(rect, egui::Rounding::same(8.0), bg);
if is_active {
painter.rect_stroke(
rect,
egui::Rounding::same(8.0),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(110, 165, 255, 190),
),
);
}
let color = if !enabled {
egui::Color32::from_gray(95)
} else if is_active {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(220)
};
let c = rect.center();
match icon {
SidebarIcon::Glyph(g) => {
painter.text(
c,
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(19.0),
color,
);
}
SidebarIcon::Text(t) => {
painter.text(
c,
egui::Align2::CENTER_CENTER,
t,
egui::FontId::proportional(12.0),
color,
);
}
SidebarIcon::Eraser => {
// Schräg liegender Radiergummi: vordere Hälfte hell, hintere Hälfte farbig
let dir = egui::vec2(1.0, -1.0).normalized();
let perp = egui::vec2(dir.y, -dir.x) * -1.0;
let (half_len, half_w) = (10.0, 5.5);
let p = |along: f32, across: f32| c + dir * along + perp * across;
let body = vec![
p(-half_len, -half_w),
p(0.0, -half_w),
p(0.0, half_w),
p(-half_len, half_w),
];
let tip = vec![
p(0.0, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(0.0, half_w),
];
let stroke = egui::Stroke::new(1.5_f32, color);
painter.add(egui::Shape::convex_polygon(
tip,
egui::Color32::from_rgb(232, 120, 140).gamma_multiply(if enabled {
1.0
} else {
0.4
}),
stroke,
));
painter.add(egui::Shape::convex_polygon(
body,
egui::Color32::TRANSPARENT,
stroke,
));
}
SidebarIcon::Ruler => {
let dir = egui::vec2(1.0, -1.0).normalized();
let perp = egui::vec2(dir.y, -dir.x);
let (half_len, half_w) = (12.0, 4.5);
let p = |along: f32, across: f32| c + dir * along + perp * across;
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(-half_len, half_w),
],
egui::Color32::TRANSPARENT,
egui::Stroke::new(1.5_f32, color),
));
for i in 1..6 {
let along = -half_len + i as f32 * 4.0;
let tick = if i % 2 == 0 { 4.0 } else { 2.5 };
painter.line_segment(
[p(along, -half_w), p(along, -half_w + tick)],
egui::Stroke::new(1.2_f32, color),
);
}
}
SidebarIcon::Lasso => {
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
let pts = [
r.left_top(),
r.right_top(),
r.right_bottom(),
r.left_bottom(),
r.left_top(),
];
painter.extend(egui::Shape::dashed_line(
&pts,
egui::Stroke::new(1.6_f32, color),
3.5,
2.5,
));
}
}
if let Some(ind) = indicator {
let bar = egui::Rect::from_min_max(
egui::pos2(rect.min.x + 9.0, rect.max.y - 5.0),
egui::pos2(rect.max.x - 9.0, rect.max.y - 2.0),
);
painter.rect_filled(bar, egui::Rounding::same(1.5), color32_from(ind));
}
}
if tooltip.is_empty() {
response
} else {
response.on_hover_text(tooltip)
}
}
/// Waagerechte Trennlinie zwischen Gruppen der Werkzeugleiste.
fn sidebar_divider(ui: &mut egui::Ui, button_size: f32) {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(button_size - 12.0, 7.0), egui::Sense::hover());
ui.painter().hline(
rect.x_range(),
rect.center().y,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
),
);
}
/// Wandelt eine RGBA-Farbe (0..1) in eine gut sichtbare egui-Farbe um.
fn color32_from(color: [f32; 4]) -> egui::Color32 {
egui::Color32::from_rgba_unmultiplied(
(color[0].clamp(0.0, 1.0) * 255.0) as u8,
(color[1].clamp(0.0, 1.0) * 255.0) as u8,
(color[2].clamp(0.0, 1.0) * 255.0) as u8,
((color[3].clamp(0.0, 1.0) * 255.0) as u8).max(180),
)
}
/// Position und Ankerpunkt eines Popovers neben einem Knopf der Werkzeugleiste,
/// immer zur Blattseite hin geöffnet.
fn popover_placement(
side: SidebarSide,
toolbar: egui::Rect,
button: egui::Rect,
) -> (egui::Pos2, egui::Align2) {
match side {
SidebarSide::Left => (
egui::pos2(toolbar.max.x + 8.0, button.min.y),
egui::Align2::LEFT_TOP,
),
SidebarSide::Right => (
egui::pos2(toolbar.min.x - 8.0, button.min.y),
egui::Align2::RIGHT_TOP,
),
}
}
/// Farbe und Beschriftung der Speicherstatus-Anzeige.
fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str) {
match status {
AutosaveStatus::Saved => (
egui::Color32::from_rgb(46, 204, 113),
t!("Alles gespeichert"),
),
AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), t!("Speichert …")),
AutosaveStatus::Pending => (
egui::Color32::from_rgb(230, 126, 34),
t!("Ungespeicherte Änderungen"),
),
AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), t!("Speicherfehler!")),
}
}
/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser.
/// Farbwahl in den Popovern: Vorgaben als Farbpunkte, dahinter 🎨 für einen frei
/// wählbaren Farbton (aufklappbar). Die Deckkraft von `color` bleibt erhalten (Textmarker
/// halbtransparent). `true`, wenn sich die Farbe geändert hat.
fn color_editor(
ui: &mut egui::Ui,
presets: &[ColorPreset],
color: &mut [f32; 4],
picker_open: &mut bool,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
for &(preset, label) in presets {
let is_sel = preset[..3]
.iter()
.zip(&color[..3])
.all(|(a, b)| (a - b).abs() < 0.01);
if color_circle_swatch(ui, preset, is_sel, crate::i18n::tr(label), 30.0).clicked() {
*color = [preset[0], preset[1], preset[2], color[3]];
changed = true;
}
}
if ui
.add(egui::SelectableLabel::new(
*picker_open,
egui::RichText::new("🎨").size(18.0),
))
.on_hover_text(t!("Eigene Farbe wählen"))
.clicked()
{
*picker_open = !*picker_open;
}
});
if *picker_open {
ui.add_space(6.0);
let [r, g, b] = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let mut picked = egui::Color32::from_rgb(r, g, b);
let picked_changed = ui
.scope(|ui| {
ui.spacing_mut().slider_width = 170.0;
egui::color_picker::color_picker_color32(
ui,
&mut picked,
egui::color_picker::Alpha::Opaque,
)
})
.inner;
if picked_changed {
let [r, g, b, _] = picked.to_array();
*color = [
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
color[3],
];
changed = true;
}
}
changed
}
/// Stift der Werkzeugleiste: ein schräg liegender Stift in seiner Farbe; die Stärke steht
/// im Tooltip.
fn pen_preset_swatch(
ui: &mut egui::Ui,
preset: PenPreset,
is_selected: bool,
tooltip: &str,
size: f32,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
if ui.is_rect_visible(rect) {
// Schräg liegender Stift ganz in seiner Farbe, damit die Stifte auf einen Blick zu
// unterscheiden sind (Issue #31); die helle Kontur hält dunkle Farben sichtbar
let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
let ink = egui::Color32::from_rgb(r, g, b);
let c = rect.center();
let dir = egui::vec2(1.0, -1.0).normalized();
let perp = egui::vec2(dir.y, -dir.x);
let half_len = size * 0.44;
let half_w = size * 0.17;
let p = |along: f32, across: f32| c + dir * along + perp * across;
let tip_start = -half_len * 0.3;
let lead_start = -half_len * 0.72;
let lead_w = half_w * (lead_start + half_len) / (tip_start + half_len);
let outline = egui::Stroke::new(1.0_f32, egui::Color32::from_gray(200));
let painter = ui.painter();
painter.add(egui::Shape::convex_polygon(
vec![
p(tip_start, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(tip_start, half_w),
],
ink,
outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(tip_start, -half_w),
p(tip_start, half_w),
],
egui::Color32::from_rgb(235, 210, 170),
outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(lead_start, -lead_w),
p(lead_start, lead_w),
],
ink,
egui::Stroke::NONE,
));
if is_selected {
ui.painter().rect_stroke(
rect.shrink(0.5),
egui::Rounding::same(7.0),
egui::Stroke::new(2.0_f32, egui::Color32::WHITE),
);
} else if response.hovered() {
ui.painter().rect_stroke(
rect.shrink(1.0),
egui::Rounding::same(7.0),
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(150)),
);
}
}
response.on_hover_text(tooltip)
}
fn color_circle_swatch(
ui: &mut egui::Ui,
color: [f32; 4],
is_selected: bool,
tooltip: &str,
diameter: f32,
) -> egui::Response {
let size = egui::vec2(diameter, diameter);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let r = (color[0].clamp(0.0, 1.0) * 255.0) as u8;
let g = (color[1].clamp(0.0, 1.0) * 255.0) as u8;
let b = (color[2].clamp(0.0, 1.0) * 255.0) as u8;
let a = ((color[3].clamp(0.0, 1.0) * 255.0) as u8).max(180);
let c32 = egui::Color32::from_rgba_unmultiplied(r, g, b, a);
// Füllung bleibt immer sichtbar; die Auswahl ist ein Ring mit Abstand außen herum,
// dunkle Farben bekommen eine helle Kontur gegen den dunklen Hintergrund
let outer = (diameter / 2.0) - 1.0;
let fill_radius = outer - 3.0;
let outline = if response.hovered() {
egui::Stroke::new(1.5_f32, egui::Color32::from_white_alpha(200))
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(110))
};
ui.painter().circle_filled(rect.center(), fill_radius, c32);
ui.painter()
.circle_stroke(rect.center(), fill_radius, outline);
if is_selected {
ui.painter().circle_stroke(
rect.center(),
outer,
egui::Stroke::new(2.0_f32, egui::Color32::WHITE),
);
}
}
response.on_hover_text(tooltip)
}
/// Spalten der Seitenauswahl.
const PAGE_PICKER_COLUMNS: usize = 4;
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
/// 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` in der Papierfarbe
/// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind
/// flacher). 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 scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
let size = egui::vec2(page.size.x, page.size.y) * scale;
let sheet = egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
);
let mut shapes = vec![egui::Shape::rect_filled(
sheet,
egui::Rounding::same(3.0),
linear_color32(paper.paper_color()),
)];
let adapt = adapt_ink && paper.is_dark();
let to_screen = |p: glam::Vec2| sheet.min + egui::vec2(p.x * scale, p.y * scale);
// Importierte PDF-Seite als helle Fläche mit Rand
if let Some(pdf) = &page.pdf_background {
let points: Vec<egui::Pos2> = pdf
.corners(page.size)
.iter()
.map(|&c| to_screen(c))
.collect();
shapes.push(egui::Shape::convex_polygon(
points,
egui::Color32::from_rgb(236, 238, 243),
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
));
}
// Bilder als Flächen unter der Tinte
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
shapes.push(egui::Shape::convex_polygon(
corners.iter().map(|&c| to_screen(c)).collect(),
egui::Color32::from_rgb(168, 182, 200),
egui::Stroke::NONE,
));
}
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
// Textfelder: je Zeile ein Strich in Textfarbe auf Höhe der Kleinbuchstaben
for stroke in &page.strokes {
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
continue;
};
let ink = if adapt {
adapt_ink_color(stroke.color)
} else {
stroke.color
};
let color = color32_from(ink).gamma_multiply(0.7);
let layout = text.layout();
let (origin, [right, down]) = crate::text::frame_axes(&corners, text.box_size());
let width = (text.size * 0.45 * down.length() * scale).clamp(0.6, 6.0);
for (i, line) in layout.lines.iter().enumerate() {
if line.width <= 0.0 {
continue;
}
let start = origin + down * (layout.baseline(i) - text.size * 0.25);
shapes.push(egui::Shape::line_segment(
[to_screen(start), to_screen(start + right * line.width)],
egui::Stroke::new(width, color),
));
}
}
for stroke in page.strokes.iter().filter(|s| !s.is_object()) {
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);
}
let width = (stroke.width * scale).clamp(0.6, 4.0);
let last = stroke.points.len().saturating_sub(1);
let points: Vec<egui::Pos2> = stroke
.points
.iter()
.enumerate()
.filter(|(i, _)| i % step == 0 || *i == last)
.map(|(_, s)| to_screen(s.position))
.collect();
match points.len() {
0 => {}
1 => shapes.push(egui::Shape::circle_filled(points[0], width / 2.0, color)),
_ => shapes.push(egui::Shape::line(points, egui::Stroke::new(width, color))),
}
}
shapes
}
/// Knöpfe für Schriftart und -größe des Text-Werkzeugs. Änderungen gelten auch für das
/// gerade bearbeitete Textfeld.
fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
let size = state.text_size_pt;
let smaller = TEXT_SIZES_PT
.iter()
.rev()
.copied()
.find(|&s| s < size - 0.01);
let larger = TEXT_SIZES_PT.iter().copied().find(|&s| s > size + 0.01);
let smaller_tip = t!("Kleinere Schrift (jetzt {size} pt)", size = size);
let larger_tip = t!("Größere Schrift (jetzt {size} pt)", size = size);
enum Change {
Font(TextFont),
/// Neue Größe; `None`: keine kleinere bzw. größere mehr
Size(Option<f32>),
}
let buttons = [
(
SidebarIcon::Text("Aa"),
t!("Normale Schrift"),
Change::Font(TextFont::Sans),
),
(
SidebarIcon::Text("{ }"),
t!("Code-Schrift (feste Zeichenbreite, Tab rückt ein)"),
Change::Font(TextFont::Mono),
),
(SidebarIcon::Text("A−"), &smaller_tip, Change::Size(smaller)),
(SidebarIcon::Text("A+"), &larger_tip, Change::Size(larger)),
];
for (icon, tooltip, change) in buttons {
let (active, enabled) = match change {
Change::Font(font) => (state.text_font == font, true),
Change::Size(new_size) => (false, new_size.is_some()),
};
if !sidebar_button(ui, btn, icon, active, enabled, None, tooltip).clicked() {
continue;
}
match change {
Change::Font(font) => state.text_font = font,
Change::Size(Some(new_size)) => {
state.text_size_pt = new_size;
state.set_export_message(t!("Schriftgröße {size} pt", size = new_size));
}
Change::Size(None) => {}
}
if let Some(edit) = &mut state.text_edit {
edit.text.font = state.text_font;
edit.text.size = state.text_size_pt * crate::text::UNITS_PER_PT;
edit.focus = true;
}
}
}
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
return;
};
let [sx, sy] = edit.axes.map(|a| a.length());
let font_size = (edit.text.size * sy * view.scale).clamp(2.0, 400.0);
// egui rundet die Glyphenhöhe auf ganze Pixel: die Umbruchbreite im selben Verhältnis
// anpassen, damit die Zeilen an denselben Stellen umbrechen wie auf dem Blatt
let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em();
let rounding = height_px.round().max(1.0) / height_px;
let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0);
let color = color32_from(if adapt {
adapt_ink_color(edit.color)
} else {
edit.color
});
egui::Area::new(egui::Id::new("text_edit_area"))
.fixed_pos(view.to_screen(edit.origin))
.order(egui::Order::Middle)
.show(ctx, |ui| {
egui::Frame::none()
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(70, 130, 230, 200),
))
.show(ui, |ui| {
let response = ui.add(
egui::TextEdit::multiline(&mut edit.text.content)
.id(egui::Id::new("text_edit_field"))
.frame(false)
.margin(egui::vec2(0.0, 0.0))
.font(egui::FontId::new(font_size, edit.text.font.egui_family()))
.text_color(color)
.desired_width(width)
.desired_rows(1)
.lock_focus(true),
);
if edit.focus {
response.request_focus();
edit.focus = false;
}
// egui 0.27 zählt Enter in einem fokussierten Feld als Klick und fordert
// den Fokus neu an – dabei geht die Tab-Sperre verloren und Tab würde
// danach den Fokus wegnehmen statt einzurücken
ui.memory_mut(|m| {
m.set_focus_lock_filter(
response.id,
egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
escape: false,
},
)
});
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
edit.finish = true;
}
}
/// 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,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
if ui.is_rect_visible(rect) {
let thumb = egui::Rect::from_min_size(
rect.min,
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
);
let painter = ui.painter();
painter.extend(page_thumbnail_shapes(
page,
thumb,
state.paper_theme,
state.adapt_ink_colors,
));
if !page.is_loaded {
painter.text(
thumb.center(),
egui::Align2::CENTER_CENTER,
"…",
egui::FontId::proportional(16.0),
egui::Color32::from_gray(140),
);
}
// Lesezeichen-Ecke für benannte Seiten
if page.title.is_some() {
let corner = thumb.right_top();
painter.add(egui::Shape::convex_polygon(
vec![
corner + egui::vec2(-14.0, 0.0),
corner,
corner + egui::vec2(0.0, 14.0),
],
egui::Color32::from_rgb(90, 150, 255),
egui::Stroke::NONE,
));
}
let border = if is_current {
egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255))
} else if response.hovered() {
egui::Stroke::new(1.5_f32, egui::Color32::from_white_alpha(200))
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
};
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 8.0),
egui::Align2::CENTER_CENTER,
number.to_string(),
egui::FontId::proportional(13.0),
if is_current {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(180)
},
);
}
match &page.title {
Some(title) => response.on_hover_text(t!(
"Seite {number} – {title}",
number = number,
title = title
)),
None => response.on_hover_text(t!("Seite {number}", number = number)),
}
}
/// Inhaltsverzeichnis der Seitenübersicht: alle Seiten mit Nummer und Titel, benannte
/// Seiten hervorgehoben. Gibt die angetippte Seite zurück.
fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
let mut chosen = None;
if notebook.pages.iter().all(|p| p.title.is_none()) {
ui.label(
egui::RichText::new(
t!("Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein."),
)
.color(egui::Color32::from_gray(150)),
);
ui.add_space(4.0);
}
for (idx, page) in notebook.pages.iter().enumerate() {
let is_current = idx == notebook.current_page_index;
let text = match &page.title {
Some(title) => egui::RichText::new(format!("{:>3} {title}", idx + 1))
.strong()
.color(egui::Color32::WHITE),
None => egui::RichText::new(format!("{:>3} –", idx + 1))
.color(egui::Color32::from_gray(130)),
};
let response = ui.add_sized(
[ui.available_width(), 32.0],
egui::SelectableLabel::new(is_current, text),
);
if response.clicked() {
chosen = Some(idx);
}
}
chosen
}
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde.
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
let mut changed = false;
let endless_now = *layout == NotebookLayout::Endless;
ui.horizontal_wrapped(|ui| {
for (option, label, tooltip) in [
(
NotebookLayout::Paged,
t!("📄 Feste Seiten"),
t!("Seite für Seite blättern"),
),
(
NotebookLayout::Continuous,
t!("📜 Durchgehend"),
t!("Seiten hängen untereinander, Mausrad und Finger scrollen \
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \
bekommt automatisch eine neue Seite."),
),
(
NotebookLayout::Endless,
t!("♾ Endlos"),
t!(
"Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. \
Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im \
Startausschnitt liegt."
),
),
] {
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
if ui
.add_enabled(
enabled,
egui::SelectableLabel::new(*layout == option, label),
)
.on_hover_text(tooltip)
.on_disabled_hover_text(tooltip)
.clicked()
&& *layout != option
{
*layout = option;
changed = true;
}
}
});
changed
}
/// 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
}
/// Knopf für eine Musterfarbe: Vorschau (kleines Karo auf der aktuellen Papierfarbe) und
/// Bezeichnung.
fn pattern_color_button(
ui: &mut egui::Ui,
pattern: PatternColor,
paper: PaperTheme,
selected: bool,
) -> egui::Response {
let response = ui.add_sized(
[82.0, 28.0],
egui::SelectableLabel::new(selected, format!(" {}", pattern.label())),
);
let swatch = egui::Rect::from_center_size(
egui::pos2(response.rect.min.x + 15.0, response.rect.center().y),
egui::vec2(16.0, 16.0),
);
let painter = ui.painter();
painter.rect_filled(
swatch,
egui::Rounding::same(3.0),
linear_color32(paper.paper_color()),
);
let line = egui::Stroke::new(1.5_f32, linear_color32(pattern.color(paper)));
for t in [0.33, 0.67] {
let x = swatch.min.x + swatch.width() * t;
let y = swatch.min.y + swatch.height() * t;
painter.line_segment(
[egui::pos2(x, swatch.min.y), egui::pos2(x, swatch.max.y)],
line,
);
painter.line_segment(
[egui::pos2(swatch.min.x, y), egui::pos2(swatch.max.x, y)],
line,
);
}
painter.rect_stroke(
swatch,
egui::Rounding::same(3.0),
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)),
);
response.on_hover_text(t!("Gilt für alle Seiten, auch beim Export"))
}
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
/// (Regler losgelassen oder per Tastatur geändert).
/// Belegung der Tasten am Stift. Tasten erscheinen, sobald sie einmal gedrückt wurden; die
/// gerade gedrückte ist markiert. `true`, wenn sich etwas geändert hat.
fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool {
use crate::settings::StylusAction;
let mut changed = false;
ui.add_space(4.0);
ui.label(egui::RichText::new(t!("Tasten am Stift")).color(egui::Color32::from_gray(200)));
if state.stylus_buttons.is_empty() {
ui.label(
egui::RichText::new(t!(
"Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \
Bis dahin radieren alle Tasten."
))
.size(11.0)
.color(egui::Color32::from_gray(150)),
);
return false;
}
let mut remove = None;
for i in 0..state.stylus_buttons.len() {
ui.horizontal(|ui| {
let pressed = state.pressed_stylus_button == Some(i);
let label = egui::RichText::new(t!("Taste {number}", number = i + 1));
ui.label(if pressed {
label.strong().color(egui::Color32::from_rgb(46, 204, 113))
} else {
label
})
.on_hover_text(t!("Leuchtet grün, solange die Taste gedrückt ist"));
let current = state.stylus_buttons[i].action;
egui::ComboBox::from_id_source(("stylus_button", i))
.selected_text(current.label())
.width(130.0)
.show_ui(ui, |ui| {
for action in StylusAction::ALL {
if ui
.selectable_label(action == current, action.label())
.clicked()
&& action != current
{
state.stylus_buttons[i].action = action;
changed = true;
}
}
});
if ui
.small_button("🗙")
.on_hover_text(t!(
"Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)"
))
.clicked()
{
remove = Some(i);
}
});
}
if let Some(i) = remove {
state.stylus_buttons.remove(i);
state.pressed_stylus_button = None;
changed = true;
}
changed
}
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
let mut save = false;
let mut track = |response: egui::Response| {
if response.drag_stopped() || (response.changed() && !response.dragged()) {
save = true;
}
};
ui.add_space(4.0);
ui.label(t!("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(t!("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(t!("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.")));
ui.label(t!("hart"));
});
ui.horizontal(|ui| {
ui.label(t!("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(t!("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(t!("Druckkurve zurücksetzen")),
)
.clicked()
{
state.pressure_curve = PressureCurve::default();
save = true;
}
save.then_some(state.pressure_curve)
}
/// Pfad des gezeigten Ordners im Dokumenten-Manager: „📚 Alle › 3. Semester › Mathe“, jeder
/// Teil antippbar.
fn folder_breadcrumbs(ui: &mut egui::Ui, state: &mut UiState, folder: &str) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let mut target = None;
if ui
.selectable_label(
folder.is_empty(),
egui::RichText::new(t!("📚 Alle")).size(13.0),
)
.clicked()
{
target = Some(String::new());
}
let mut path = String::new();
for name in folder.split('/').filter(|n| !n.is_empty()) {
ui.label(egui::RichText::new("›").color(egui::Color32::from_gray(140)));
if !path.is_empty() {
path.push('/');
}
path.push_str(name);
if ui
.selectable_label(path == folder, egui::RichText::new(name).size(13.0))
.clicked()
{
target = Some(path.clone());
}
}
if let Some(target) = target {
state.notebook_folder = target;
state.renaming_folder = None;
state.confirm_delete_folder = None;
}
});
}
/// Zeile eines Unterordners: antippen öffnet ihn; umbenennen und löschen (mit Rückfrage).
fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut Vec<UiAction>) {
let name = crate::library::folder_name(path);
if state.renaming_folder.as_deref() == Some(path) {
ui.horizontal(|ui| {
let edit = ui.add(
egui::TextEdit::singleline(&mut state.renaming_title_input)
.margin(egui::vec2(8.0, 8.0))
.desired_width(260.0),
);
if ui.button("✔").clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
actions.push(UiAction::RenameFolder {
path: path.to_string(),
new_name: state.renaming_title_input.trim().to_string(),
});
state.renaming_folder = None;
}
if ui.button("🗙").clicked() {
state.renaming_folder = None;
}
});
return;
}
if state.confirm_delete_folder.as_deref() == Some(path) {
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(170, 50, 45, 40))
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.add(
egui::Label::new(t!(
"Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine \
Ebene nach oben, gelöscht wird kein Notizbuch.",
name = name
))
.wrap(true),
);
ui.horizontal(|ui| {
if ui
.button(
egui::RichText::new(t!("Ordner löschen"))
.color(egui::Color32::WHITE),
)
.clicked()
{
actions.push(UiAction::DeleteFolder(path.to_string()));
state.confirm_delete_folder = None;
}
if ui.button(t!("Abbrechen")).clicked() {
state.confirm_delete_folder = None;
}
});
});
});
return;
}
let ids = state.available_notebooks.iter().map(|s| s.id.as_str());
let count = state.library.count_within(path, ids);
let mut open = false;
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
))
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
// Erst die Knöpfe (rechts), der Name füllt den Rest und öffnet den Ordner
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(
egui::RichText::new("🗑")
.size(16.0)
.color(egui::Color32::from_rgb(255, 120, 120)),
)
.on_hover_text(t!("Ordner löschen (die Notizbücher bleiben erhalten)"))
.clicked()
{
state.confirm_delete_folder = Some(path.to_string());
}
if ui
.button(egui::RichText::new("📂").size(16.0))
.on_hover_text(t!("Ordner samt Inhalt in einen anderen Ordner verschieben"))
.clicked()
{
state.moving = Some(MoveSubject::Folder(path.to_string()));
}
if ui
.button(egui::RichText::new("✏").size(16.0))
.on_hover_text(t!("Ordner umbenennen"))
.clicked()
{
state.renaming_folder = Some(path.to_string());
state.renaming_title_input = name.to_string();
}
let title_area = ui
.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.vertical(|ui| {
ui.set_min_width(ui.available_width());
ui.label(
egui::RichText::new(format!("📁 {name}"))
.strong()
.size(13.0)
.color(egui::Color32::WHITE),
);
ui.label(
egui::RichText::new(match count {
1 => t!("1 Notizbuch").to_string(),
n => t!("{n} Notizbücher", n = n),
})
.size(11.0)
.color(egui::Color32::from_gray(160)),
);
})
})
.inner;
open = title_area
.response
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked();
});
});
if open {
state.notebook_folder = path.to_string();
}
}
/// Zielordner für das Verschieben eines Notizbuchs oder Ordners: oberste Ebene und alle
/// Ordner als Baum (ein Ordner nicht in sich selbst oder seine Unterordner).
fn move_target_list(
ui: &mut egui::Ui,
state: &mut UiState,
subject: &MoveSubject,
actions: &mut Vec<UiAction>,
) {
let (title, current, folders) = match subject {
MoveSubject::Notebook(id) => (
state
.available_notebooks
.iter()
.find(|s| &s.id == id)
.map_or(id.as_str(), |s| s.title.as_str())
.to_string(),
state.library.folder_of(id).to_string(),
state.library.all_folders(),
),
MoveSubject::Folder(path) => (
format!("📁 {}", crate::library::folder_name(path)),
crate::library::parent_folder(path).to_string(),
state.library.folder_move_targets(path),
),
};
ui.label(egui::RichText::new(t!("„{title}“ verschieben nach:", title = title)).strong());
ui.add_space(4.0);
let mut targets = vec![(String::new(), t!("📚 Oberste Ebene").to_string(), 0)];
for path in folders {
let depth = path.matches('/').count() + 1;
targets.push((
path.to_string(),
format!("📁 {}", crate::library::folder_name(path)),
depth,
));
}
egui::ScrollArea::vertical()
.max_height(300.0)
.show(ui, |ui| {
for (path, label, depth) in targets {
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 18.0);
let here = path == current;
if ui
.add_enabled(!here, egui::Button::new(label))
.on_disabled_hover_text(t!("Liegt bereits hier"))
.clicked()
{
actions.push(match subject {
MoveSubject::Notebook(id) => UiAction::MoveNotebook {
id: id.clone(),
folder: path.clone(),
},
MoveSubject::Folder(folder) => UiAction::MoveFolder {
path: folder.clone(),
new_parent: path.clone(),
},
});
state.moving = None;
}
});
}
});
ui.add_space(6.0);
if ui.button(t!("Abbrechen")).clicked() {
state.moving = None;
}
}
/// „Seite löschen“ mit Rückfrage im ☰-Menü und „Gelöschte Seite wiederherstellen“.
fn delete_page_buttons(
ui: &mut egui::Ui,
state: &mut UiState,
notebook: &Notebook,
actions: &mut Vec<UiAction>,
) {
let number = notebook.current_page_index + 1;
if state.confirm_delete_page {
ui.label(
egui::RichText::new(t!(
"Seite {number} mit allem Inhalt löschen?",
number = number
))
.color(egui::Color32::from_rgb(255, 170, 160)),
);
ui.horizontal(|ui| {
let half = (ui.available_width() - ui.spacing().item_spacing.x) / 2.0;
if ui
.add_sized(
[half, 30.0],
egui::Button::new(
egui::RichText::new(t!("Löschen")).color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(170, 50, 45)),
)
.clicked()
{
actions.push(UiAction::DeleteCurrentPage);
state.confirm_delete_page = false;
}
if ui
.add_sized([half, 30.0], egui::Button::new(t!("Abbrechen")))
.clicked()
{
state.confirm_delete_page = false;
}
});
} else {
let only_page = notebook.pages.len() < 2;
let response = ui
.add_enabled_ui(!only_page, |ui| {
ui.add_sized(
[ui.available_width(), 30.0],
egui::Button::new(t!("🗑 Seite {number} löschen …", number = number)),
)
})
.inner
.on_hover_text(t!(
"Löscht die aktuelle Seite; danach im Menü wiederherstellbar"
))
.on_disabled_hover_text(t!(
"Die einzige Seite eines Notizbuchs kann nicht gelöscht werden"
));
if response.clicked() {
state.confirm_delete_page = true;
}
}
if let Some(number) = state.restorable_page {
if ui
.add_sized(
[ui.available_width(), 30.0],
egui::Button::new(t!(
"⟲ Gelöschte Seite {number} wiederherstellen",
number = number
)),
)
.clicked()
{
actions.push(UiAction::RestoreDeletedPage);
}
}
}
/// Kontextmenü nach langem Drücken auf das Blatt: Einfügen an dieser Stelle. Ein Tippen
/// daneben oder Esc schließt es.
fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
let Some(menu) = state.context_menu else {
return;
};
let response = egui::Area::new(egui::Id::new("canvas_context_menu"))
.fixed_pos(menu.screen)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
popover_frame().show(ui, |ui| {
touch_friendly_spacing(ui);
ui.set_width(220.0);
if ui
.add_sized([220.0, 36.0], egui::Button::new(t!("📋 Einfügen")))
.on_hover_text(t!(
"Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen"
))
.clicked()
{
actions.push(UiAction::PasteAt(menu.page));
state.context_menu = None;
}
if ui
.add_sized([220.0, 36.0], egui::Button::new(t!("🖼 Bild aus Datei …")))
.clicked()
{
state.open_image_picker();
}
})
});
let rect = response.response.rect;
let pressed_outside = ctx.input(|i| {
i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !rect.contains(p))
});
if pressed_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
state.context_menu = None;
}
}
/// Vorschau beim Platzschaffen: Linie an der Ansatzhöhe und das Band, das eingefügt (grün)
/// bzw. geschlossen (orange) wird, mit Angabe in Millimetern.
fn space_preview(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let (Some(drag), Some(view)) = (state.space_drag, state.page_view) else {
return;
};
let Some(page) = notebook.pages.get(drag.page_index) else {
return;
};
let dy = drag.clamped_dy();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("space_preview"),
));
let left = view.to_screen(glam::Vec2::new(0.0, drag.y));
let right = view.to_screen(glam::Vec2::new(page.size.x, drag.y));
let moved = view.to_screen(glam::Vec2::new(page.size.x, drag.y + dy));
let (fill, line) = if dy >= 0.0 {
(
egui::Color32::from_rgba_unmultiplied(46, 204, 113, 50),
egui::Color32::from_rgb(39, 174, 96),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(230, 126, 34, 60),
egui::Color32::from_rgb(211, 84, 0),
)
};
let band = egui::Rect::from_two_pos(left, moved);
painter.rect_filled(band, 0.0, fill);
painter.line_segment([left, right], egui::Stroke::new(2.0_f32, line));
painter.line_segment(
[egui::pos2(left.x, moved.y), moved],
egui::Stroke::new(2.0_f32, line),
);
let mm = dy / crate::document::UNITS_PER_MM;
painter.text(
egui::pos2(right.x - 8.0, moved.y.max(left.y) + 4.0),
egui::Align2::RIGHT_TOP,
if dy >= 0.0 {
t!("⏷ {mm} mm Platz", mm = format!("{mm:.0}"))
} else {
format!("⏶ {:.0} mm", -mm)
},
egui::FontId::proportional(14.0),
line,
);
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
return;
};
let stack = notebook.page_stack();
let screen = ctx.screen_rect();
let color = if state.paper_theme.is_dark() {
egui::Color32::from_white_alpha(140)
} else {
egui::Color32::from_black_alpha(200)
};
let painter = ctx.layer_painter(egui::LayerId::background());
for i in 1..stack.len() {
let y = stack.origins[i].y;
let left = stack.origins[i - 1].x.min(stack.origins[i].x);
let right = (stack.origins[i - 1].x + stack.sizes[i - 1].x)
.max(stack.origins[i].x + stack.sizes[i].x);
let a = view.to_screen(glam::Vec2::new(left, y));
let b = view.to_screen(glam::Vec2::new(right, y));
if a.y < screen.top() - 1.0 || a.y > screen.bottom() + 1.0 {
continue;
}
painter.line_segment([a, b], egui::Stroke::new(1.5_f32, color));
}
}
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
state.ruler_drag = None;
return;
};
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
let (pressed, down, pos) = ctx.input(|i| {
(
i.pointer.primary_pressed(),
i.pointer.primary_down(),
i.pointer.interact_pos(),
)
});
if pressed && !ctx.is_pointer_over_area() {
if let Some(pos) = pos {
let page = view.to_page(pos);
state.ruler_drag = match ruler.hit(page) {
Some(RulerHit::Body) => Some(RulerDrag::Move {
grab_offset: ruler.center - page,
}),
Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate),
None => None,
};
}
}
match (state.ruler_drag, down, pos) {
(Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => {
ruler.center = view.to_page(pos) + grab_offset;
}
(Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)),
(_, false, _) => state.ruler_drag = None,
_ => {}
}
state.ruler = Some(ruler);
// Zeichnen (unter Leiste und Dialogen)
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("ruler_overlay"),
));
let corners: Vec<egui::Pos2> = ruler.corners().iter().map(|&c| view.to_screen(c)).collect();
painter.add(egui::Shape::convex_polygon(
corners.clone(),
egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200),
),
));
let ink = egui::Color32::from_rgb(45, 52, 66);
let axis = ruler.axis();
let normal = ruler.normal();
let mm = crate::document::UNITS_PER_MM;
let px_per_mm = view.scale * mm;
let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32;
let half_w = crate::ruler::RULER_WIDTH / 2.0;
// Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen
for side in [-1.0_f32, 1.0] {
let edge = ruler.center + normal * side * half_w;
for i in -half_len_mm..=half_len_mm {
let tick_mm = if i % 10 == 0 {
4.0
} else if i % 5 == 0 {
2.5
} else if px_per_mm >= 3.0 {
1.5
} else {
continue;
};
let base = edge + axis * (i as f32 * mm);
let tip = base - normal * side * tick_mm * mm;
painter.line_segment(
[view.to_screen(base), view.to_screen(tip)],
egui::Stroke::new(1.0_f32, ink),
);
}
}
// Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt
if px_per_mm >= 1.2 {
let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0));
let edge = ruler.center - normal * half_w;
for cm in 0..=(half_len_mm * 2 / 10) {
let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm;
let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm);
let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink));
let offset = egui::vec2(galley.size().x / 2.0, 0.0);
let offset = egui::vec2(
offset.x * axis.x - offset.y * axis.y,
offset.x * axis.y + offset.y * axis.x,
);
let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink);
text.angle = ruler.angle;
painter.add(text);
}
}
// Winkelanzeige und Dreh-Griff
let center = view.to_screen(ruler.center);
let label = format!("{}°", ruler.display_degrees());
let galley = ctx.fonts(|f| {
f.layout_no_wrap(
label,
egui::FontId::proportional(13.0),
egui::Color32::WHITE,
)
});
let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0));
painter.rect_filled(
pill,
egui::Rounding::same(9.0),
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220),
);
painter.galley(
pill.center() - galley.size() / 2.0,
galley,
egui::Color32::WHITE,
);
let handle = view.to_screen(ruler.handle_center());
let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0);
let active = state.ruler_drag == Some(RulerDrag::Rotate);
painter.circle(
handle,
radius,
if active {
egui::Color32::from_rgb(70, 120, 210)
} else {
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200)
},
egui::Stroke::new(1.0_f32, egui::Color32::WHITE),
);
painter.text(
handle,
egui::Align2::CENTER_CENTER,
"⟳",
egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)),
egui::Color32::WHITE,
);
}
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
/// Ordnern und passenden Dateien.
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
let mut close = false;
let mut navigate: Option<std::path::PathBuf> = None;
let mut go_up = false;
let mut chosen: Option<std::path::PathBuf> = None;
let Some(picker) = state.file_picker.as_ref() else {
return;
};
let picker_kind = picker.kind;
let (title, header, file_icon, empty_hint, tip) = match picker.kind {
PickerKind::Image => (
t!("Bild einfügen"),
t!("🖼 Bild einfügen"),
"🖼",
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
),
PickerKind::Pdf => (
t!("PDF importieren"),
t!("📄 PDF importieren"),
"📄",
t!("Keine Ordner oder PDF-Dateien"),
t!(
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
Beim Export liegen deine Notizen über den Originalseiten."
),
),
};
dialog_window(title, state, false).show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
if dialog_header(ui, header, true) {
close = true;
}
ui.horizontal_wrapped(|ui| {
for (label, path) in crate::file_picker::shortcuts() {
if ui
.selectable_label(picker.dir == path, crate::i18n::tr(label))
.clicked()
{
navigate = Some(path);
}
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui
.add_enabled(picker.dir.parent().is_some(), egui::Button::new("⏶"))
.on_hover_text(t!("Übergeordneter Ordner"))
.clicked()
{
go_up = true;
}
ui.add(
egui::Label::new(
egui::RichText::new(picker.dir.display().to_string())
.color(egui::Color32::from_gray(190)),
)
.truncate(true),
);
});
if let Some(err) = &picker.error {
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
}
ui.add_space(4.0);
egui::ScrollArea::vertical()
.max_height((ctx.screen_rect().height() - 330.0).clamp(160.0, 420.0))
.show(ui, |ui| {
ui.set_width(ui.available_width());
if picker.entries.is_empty() {
ui.label(egui::RichText::new(empty_hint).color(egui::Color32::from_gray(150)));
}
for entry in &picker.entries {
let label = if entry.is_dir {
format!("📁 {}", entry.name)
} else {
format!("{file_icon} {}", entry.name)
};
let button = egui::Button::new(label)
.frame(false)
.min_size(egui::vec2(ui.available_width(), 34.0));
let response = ui.add(button);
if response.clicked() {
if entry.is_dir {
navigate = Some(entry.path.clone());
} else {
chosen = Some(entry.path.clone());
}
}
}
});
ui.add_space(6.0);
ui.label(
egui::RichText::new(tip)
.size(11.5)
.color(egui::Color32::from_gray(150)),
);
});
if let Some(picker) = state.file_picker.as_mut() {
if go_up {
picker.go_up();
}
if let Some(dir) = navigate {
picker.navigate(&dir);
}
}
if let Some(path) = chosen {
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
actions.push(match picker_kind {
PickerKind::Image => UiAction::InsertImageFile(path),
PickerKind::Pdf => UiAction::ImportPdf(path),
});
close = true;
}
if close {
state.file_picker = None;
}
}
/// Rahmen der Popover neben der Werkzeugleiste.
fn popover_frame() -> egui::Frame {
egui::Frame::none()
.fill(egui::Color32::from_rgb(30, 30, 34))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_white_alpha(40),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
})
}
/// Deckender, einheitlicher Rahmen für Dialogfenster (`danger` = rote Warnfenster).
fn dialog_frame(danger: bool) -> egui::Frame {
let (fill, stroke) = if danger {
(
egui::Color32::from_rgb(44, 24, 26),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(235, 87, 87)),
)
} else {
(
egui::Color32::from_rgb(30, 30, 34),
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)),
)
};
egui::Frame::none()
.fill(fill)
.rounding(egui::Rounding::same(14.0))
.stroke(stroke)
.inner_margin(egui::Margin::symmetric(20.0, 16.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(160),
})
}
/// Dialogfenster ohne egui-Titelleiste (der Titel steht in [`dialog_header`]), zentriert über
/// dem freien Bereich neben der Werkzeugleiste. Warnfenster sitzen in der Bildschirmmitte.
fn dialog_window(title: &'static str, state: &UiState, danger: bool) -> egui::Window<'static> {
let shift = match state.sidebar_side {
SidebarSide::Left => state.sidebar_reserved_width() / 2.0,
SidebarSide::Right => -state.sidebar_reserved_width() / 2.0,
};
let (align, y) = if danger {
(egui::Align2::CENTER_CENTER, 0.0)
} else {
(egui::Align2::CENTER_TOP, 60.0)
};
egui::Window::new(title)
.title_bar(false)
.collapsible(false)
.resizable(false)
.anchor(align, egui::vec2(shift, y))
.frame(dialog_frame(danger))
}
/// Kopfzeile eines Dialogs mit Titel und optionalem Schließen-Knopf.
/// Gibt `true` zurück, wenn „Schließen“ getippt wurde.
fn dialog_header(ui: &mut egui::Ui, title: &str, closable: bool) -> bool {
let mut close = false;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.strong()
.size(16.0)
.color(egui::Color32::WHITE),
);
if closable {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = close_button(ui).clicked();
});
}
});
ui.add_space(4.0);
ui.separator();
ui.add_space(6.0);
close
}
/// Schließen-Knopf mit fingertauglicher Trefferfläche.
fn close_button(ui: &mut egui::Ui) -> egui::Response {
ui.add_sized(
[36.0, 36.0],
egui::Button::new(egui::RichText::new("🗙").size(16.0)).frame(false),
)
.on_hover_text(t!("Schließen (Esc)"))
}
/// Größere Knöpfe, Eingabefelder und Schieberegler für Finger und Stift.
fn touch_friendly_spacing(ui: &mut egui::Ui) {
let spacing = ui.spacing_mut();
spacing.button_padding = egui::vec2(12.0, 8.0);
spacing.interact_size = egui::vec2(40.0, 36.0);
spacing.item_spacing = egui::vec2(8.0, 8.0);
spacing.slider_width = 200.0;
}
/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt.
///
/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen
/// Pixeln. `layer_id_at` erfasst die egui-Bereiche des letzten Frames (Leiste, Popover,
/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch).
/// Ebenen der Ordnung `Background` decken den ganzen Bildschirm ab und gehören zum
/// Zeichenblatt, nicht zur Oberfläche.
pub fn is_screen_point_in_ui(
ctx: &egui::Context,
state: &UiState,
screen_pos: (f32, f32),
scale: f32,
) -> bool {
let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale);
state.is_point_in_ui(pos.x, pos.y)
|| ctx
.layer_id_at(pos)
.is_some_and(|layer| layer.order != egui::Order::Background)
}
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
///
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
pub fn render_ui(
ctx: &egui::Context,
state: &mut UiState,
notebook: &Notebook,
autosave_status: AutosaveStatus,
updater: &crate::updater::UpdaterController,
) -> Vec<UiAction> {
let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None;
let mut high_btn_rect = None;
let mut text_color_btn_rect = None;
let mut more_btn_rect = None;
let mut page_btn_rect = None;
// 1. Senkrechte Werkzeugleiste, seitlich oben angedockt. Bewusst nicht vertikal
// zentriert: sonst würden alle Knöpfe springen, sobald sich die Höhe ändert.
let (anchor, offset) = match state.sidebar_side {
SidebarSide::Left => (
egui::Align2::LEFT_TOP,
egui::vec2(UiState::SIDEBAR_MARGIN, UiState::SIDEBAR_MARGIN),
),
SidebarSide::Right => (
egui::Align2::RIGHT_TOP,
egui::vec2(-UiState::SIDEBAR_MARGIN, UiState::SIDEBAR_MARGIN),
),
};
// Knopfgröße an die Fensterhöhe anpassen (40 px, schrittweise bis 28 px); reicht auch das nicht,
// wird die Leiste scrollbar statt abgeschnitten
let max_height = (ctx.screen_rect().height() - 2.0 * UiState::SIDEBAR_MARGIN - 12.0).max(120.0);
// Größe nach dem höchsten Fall (Auswahl mit Aktionen), damit Knöpfe beim
// Werkzeugwechsel weder schrumpfen noch verrutschen
let btn = UiState::sidebar_button_size(ActiveTool::Select, max_height, state.continuous_scroll);
let area_response = egui::Area::new(egui::Id::new("floating_toolbar_area"))
.anchor(anchor, offset)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 225))
.rounding(egui::Rounding::same(UiState::TOOLBAR_ROUNDING))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::same(6.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 4.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(100),
})
.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_height(max_height)
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded,
)
.show(ui, |ui| {
// Zentriertes Layout nimmt sonst die volle Fensterbreite ein
ui.set_width(btn);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 3.0);
// --- Notizbücher ---
let doc_tooltip = t!(
"Notizbücher verwalten (aktuell: {title})",
title = if state.document_title.is_empty() {
t!("Notizbuch")
} else {
state.document_title.as_str()
}
);
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("📚"),
state.is_document_menu_open,
true,
None,
&doc_tooltip,
)
.clicked()
{
let open = !state.is_document_menu_open;
state.close_overlays();
state.is_document_menu_open = open;
if open {
// Im Ordner des geöffneten Notizbuchs beginnen
state.notebook_folder = state
.library
.folder_of(&state.current_notebook_id)
.to_string();
state.renaming_folder = None;
state.confirm_delete_folder = None;
state.moving = None;
actions.push(UiAction::RefreshNotebooks);
}
}
sidebar_divider(ui, btn);
// --- Werkzeuge ---
let pen_resp = sidebar_button(
ui,
btn,
SidebarIcon::Glyph("✏"),
state.active_tool == ActiveTool::Pen,
true,
Some(state.pen_color),
if state.is_pen_menu_open {
""
} else {
t!("Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke")
},
);
pen_btn_rect = Some(pen_resp.rect);
if pen_resp.clicked() {
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
if state.active_tool != ActiveTool::Pen {
state.active_tool = ActiveTool::Pen;
state.is_pen_menu_open = false;
actions.push(UiAction::SetTool(ActiveTool::Pen));
actions.push(UiAction::SetColor(state.pen_color));
actions.push(UiAction::SetStrokeWidthMm(
state.pen_stroke_width_mm,
));
} else {
let open = !state.is_pen_menu_open;
state.close_overlays();
state.is_pen_menu_open = open;
}
}
let high_resp = sidebar_button(
ui,
btn,
SidebarIcon::Glyph("🖊"),
state.active_tool == ActiveTool::Highlighter,
true,
Some(state.highlighter_color),
if state.is_highlighter_menu_open {
""
} else {
t!("Textmarker – erneut tippen für Farbe & Stärke")
},
);
high_btn_rect = Some(high_resp.rect);
if high_resp.clicked() {
state.is_pen_menu_open = false;
state.is_more_menu_open = false;
if state.active_tool != ActiveTool::Highlighter {
state.active_tool = ActiveTool::Highlighter;
state.is_highlighter_menu_open = false;
actions.push(UiAction::SetTool(ActiveTool::Highlighter));
actions.push(UiAction::SetColor(state.highlighter_color));
actions.push(UiAction::SetStrokeWidthMm(
state.highlighter_stroke_width_mm,
));
} else {
let open = !state.is_highlighter_menu_open;
state.close_overlays();
state.is_highlighter_menu_open = open;
}
}
for (tool, icon, tooltip) in [
(
ActiveTool::Shape,
SidebarIcon::Glyph("📐"),
t!("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)"),
),
(
ActiveTool::Eraser,
SidebarIcon::Eraser,
t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"),
),
(
ActiveTool::Select,
SidebarIcon::Lasso,
t!("Auswählen: verschieben, drehen, skalieren, kopieren (V)"),
),
(
ActiveTool::Space,
SidebarIcon::Glyph("↕"),
t!("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \
darunter rutscht mit, auch auf die folgenden Seiten; nach oben \
schließt leeren Platz"),
),
(
ActiveTool::Text,
SidebarIcon::Glyph("T"),
t!("Text (T): ins Blatt tippen und schreiben, vorhandenen Text \
antippen zum Ändern; Esc beendet"),
),
] {
if sidebar_button(
ui,
btn,
icon,
state.active_tool == tool,
true,
None,
tooltip,
)
.clicked()
{
state.active_tool = tool;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
actions.push(UiAction::SetTool(tool));
}
}
// --- Lineal (ein-/ausblenden) ---
if sidebar_button(
ui,
btn,
SidebarIcon::Ruler,
state.ruler.is_some(),
true,
None,
t!("Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \
Striche an einer Kante werden gerade"),
)
.clicked()
{
state.toggle_ruler();
}
// --- Stifte bzw. Farben des aktiven Werkzeugs ---
let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape);
// Fester Platz für die längste Palette, damit die Knöpfe darunter
// beim Werkzeugwechsel nicht springen
sidebar_divider(ui, btn);
let swatch = UiState::sidebar_swatch_size(btn);
let mut shown = 0;
if pens {
shown = PEN_PRESET_COUNT;
for i in 0..PEN_PRESET_COUNT {
let preset = state.pen_presets[i];
let selected = i == state.active_pen;
let tooltip = if selected {
t!(
"Stift {number} ({width} mm) – erneut tippen für Farbe & Stärke; \
Änderungen merkt sich der Stift",
number = i + 1,
width = format!("{:.2}", preset.width_mm)
)
} else {
t!("Stift {number} ({width} mm)", number = i + 1, width = format!("{:.2}", preset.width_mm))
};
let response =
pen_preset_swatch(ui, preset, selected, &tooltip, swatch);
if selected {
// Die Einstellungen erscheinen neben dem gewählten Stift
pen_btn_rect = Some(response.rect);
}
if response.clicked() {
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
if selected {
let open = !state.is_pen_menu_open;
state.close_overlays();
state.is_pen_menu_open = open;
} else {
state.is_pen_menu_open = false;
state.select_pen(i);
actions.push(UiAction::SetColor(state.pen_color));
actions.push(UiAction::SetStrokeWidthMm(
state.pen_stroke_width_mm,
));
}
}
}
}
// Eigene Farben von Textmarker und Text: antippen wählt, erneut
// tippen passt die Farbe an. Die Auswahl wird in die Farben der
// Stifte umgefärbt.
let tool = state.active_tool;
let slots: Option<Vec<[f32; 4]>> = match tool {
ActiveTool::Highlighter => Some(state.highlighter_colors.to_vec()),
ActiveTool::Text => Some(state.text_colors.to_vec()),
ActiveTool::Select => {
Some(state.pen_presets.iter().map(|p| p.color).collect())
}
_ => None,
};
if let Some(colors) = slots {
shown = colors.len();
for (i, color) in colors.into_iter().enumerate() {
let selected = match tool {
ActiveTool::Highlighter => i == state.active_highlighter,
ActiveTool::Text => {
i == state.active_text_color
&& state.text_color == color
}
_ => false,
};
let tooltip = match tool {
ActiveTool::Select => {
t!("Auswahl in der Farbe von Stift {number} umfärben", number = i + 1)
}
_ if selected => t!(
"Farbe {number} – erneut tippen, um sie anzupassen",
number = i + 1
),
_ => t!("Farbe {number}", number = i + 1),
};
let response =
color_circle_swatch(ui, color, selected, &tooltip, swatch);
if selected {
// Das Farb-Popover erscheint neben der gewählten Farbe
match tool {
ActiveTool::Highlighter => high_btn_rect = Some(response.rect),
_ => text_color_btn_rect = Some(response.rect),
}
}
if !response.clicked() {
continue;
}
match tool {
ActiveTool::Highlighter if selected => {
let open = !state.is_highlighter_menu_open;
state.close_overlays();
state.is_highlighter_menu_open = open;
}
ActiveTool::Highlighter => {
state.close_overlays();
state.select_highlighter_color(i);
actions.push(UiAction::SetColor(color));
}
ActiveTool::Text if selected => {
let open = !state.is_text_color_menu_open;
state.close_overlays();
state.is_text_color_menu_open = open;
}
ActiveTool::Text => {
state.close_overlays();
state.active_text_color = i;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
_ => actions.push(UiAction::SetColor(color)),
}
}
}
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(t!("Alle")),
None,
t!("Alle Striche radieren"),
),
(
EraserMode::Partial,
SidebarIcon::Glyph("✂"),
None,
t!("Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)"),
),
(
EraserMode::HighlighterOnly,
SidebarIcon::Glyph("🖊"),
Some(state.highlighter_color),
t!("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;
}
}
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
if state.active_tool == ActiveTool::Select {
let locked = state.selection_aspect_lock;
let (icon, tooltip) = if locked == Some(true) {
("🔒", t!("Seitenverhältnis gesperrt – tippen zum Freigeben"))
} else {
("🔓", t!("Seitenverhältnis frei – tippen zum Sperren"))
};
if sidebar_button(
ui,
swatch,
SidebarIcon::Glyph(icon),
locked == Some(true),
locked.is_some(),
None,
tooltip,
)
.clicked()
{
actions.push(UiAction::ToggleAspectLock);
}
used += swatch + spacing;
}
let reserved =
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
if reserved > used {
ui.add_space(reserved - used);
}
// --- Aktionen für die Auswahl ---
if state.active_tool == ActiveTool::Select {
sidebar_divider(ui, btn);
for (icon, tooltip, action) in [
("📋", t!("Kopieren (Strg+C)"), UiAction::CopySelected),
("📥", t!("Einfügen (Strg+V)"), UiAction::PasteSelected),
("🔄", t!("Um 90° drehen (R)"), UiAction::RotateSelected),
("🗑", t!("Löschen (Entf)"), UiAction::DeleteSelected),
] {
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph(icon),
false,
true,
None,
tooltip,
)
.clicked()
{
actions.push(action);
}
}
}
// --- Schrift des Text-Werkzeugs ---
if state.active_tool == ActiveTool::Text {
sidebar_divider(ui, btn);
text_style_buttons(ui, btn, state);
}
sidebar_divider(ui, btn);
// --- Undo & Redo ---
let history = &notebook.current_page().history;
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⟲"),
false,
history.can_undo(),
None,
t!("Rückgängig (Strg+Z)"),
)
.clicked()
{
actions.push(UiAction::Undo);
}
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⟳"),
false,
history.can_redo(),
None,
t!("Wiederholen (Strg+Y)"),
)
.clicked()
{
actions.push(UiAction::Redo);
}
sidebar_divider(ui, btn);
// --- Seitennavigation ---
// In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt
// es nur in der Einzelseitenansicht
let paging = !state.continuous_scroll && !state.endless;
if paging
&& sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏶"),
false,
notebook.current_page_index > 0,
None,
t!("Vorherige Seite (Bild auf)"),
)
.clicked()
{
actions.push(UiAction::PreviousPage);
}
let page_text = format!(
"{}/{}",
notebook.current_page_index + 1,
notebook.pages.len()
);
let page_resp = sidebar_button(
ui,
btn,
SidebarIcon::Text(&page_text),
state.show_page_jump_popup,
!state.endless,
None,
t!("Seite wählen"),
);
page_btn_rect = Some(page_resp.rect);
if page_resp.clicked() {
let open = !state.show_page_jump_popup;
state.close_overlays();
state.show_page_jump_popup = open;
}
if paging
&& sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏷"),
false,
notebook.current_page_index + 1 < notebook.pages.len(),
None,
t!("Nächste Seite (Bild ab)"),
)
.clicked()
{
actions.push(UiAction::NextPage);
}
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("➕"),
false,
!state.endless,
None,
t!("Neue Seite nach dieser einfügen"),
)
.clicked()
{
state.close_overlays();
actions.push(UiAction::AddPage);
}
sidebar_divider(ui, btn);
// --- ☰-Menü mit Status-Punkt (Speichern) und Update-Hinweis ---
let update_available = matches!(
updater.status(),
crate::updater::UpdaterStatus::UpdateAvailable(_)
| crate::updater::UpdaterStatus::DownloadFinished { .. }
);
let more_resp = sidebar_button(
ui,
btn,
SidebarIcon::Glyph("☰"),
state.is_more_menu_open,
true,
None,
if update_available {
t!("Weitere Funktionen – Update verfügbar")
} else {
t!("Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen")
},
);
more_btn_rect = Some(more_resp.rect);
let (dot_color, _) = autosave_status_style(autosave_status);
ui.painter().circle_filled(
more_resp.rect.right_top() + egui::vec2(-7.0, 7.0),
3.5,
dot_color,
);
if update_available {
ui.painter().circle_filled(
more_resp.rect.left_top() + egui::vec2(7.0, 7.0),
3.5,
egui::Color32::from_rgb(52, 152, 219),
);
}
if more_resp.clicked() {
let open = !state.is_more_menu_open;
state.close_overlays();
state.is_more_menu_open = open;
}
});
});
})
.response
.rect
});
// Leisten-Rechteck für pixelgenaue Event-Isolation speichern
state.toolbar_rect = Some(area_response.inner);
// 2. SCHWEBENDES POPOVER-MENÜ (FÜR STIFT ODER TEXTMARKER)
let mut popover_rect = None;
if state.is_pen_menu_open {
if let Some(btn_rect) = pen_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("pen_settings_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
// Titelzeile mit Schließen-Button
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!(
"Stift {number}",
number = state.active_pen + 1
))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_pen_menu_open = false;
}
},
);
});
ui.add_space(8.0);
// Zeile 1: Farbauswahl (Color Swatches)
ui.label(
egui::RichText::new(t!("FARBE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
if color_editor(
ui,
&PEN_COLOR_PRESETS,
&mut state.pen_color,
&mut state.show_color_picker,
) {
actions.push(UiAction::SetColor(state.pen_color));
}
ui.add_space(10.0);
// Zeile 2: Dicken-Presets mit visueller Linien-Vorschau
ui.label(
egui::RichText::new(t!("DICKEN-PRESETS"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for (preset, label) in PEN_WIDTH_PRESETS {
let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05;
if stroke_preview_button(ui, is_sel, preset, label, state.pen_color)
.clicked()
{
state.pen_stroke_width_mm = preset;
actions.push(UiAction::SetStrokeWidthMm(preset));
}
}
});
ui.add_space(10.0);
// Zeile 3: Feinjustierung (Slider)
ui.label(
egui::RichText::new(t!("STUFENLOSE STÄRKE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(2.0);
if ui
.add(
egui::Slider::new(&mut state.pen_stroke_width_mm, 0.15..=5.0)
.suffix(" mm")
.step_by(0.05),
)
.changed()
{
actions.push(UiAction::SetStrokeWidthMm(state.pen_stroke_width_mm));
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_highlighter_menu_open {
if let Some(btn_rect) = high_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("highlighter_settings_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!(
"Textmarker – Farbe {number}",
number = state.active_highlighter + 1
))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_highlighter_menu_open = false;
}
},
);
});
ui.add_space(8.0);
// Zeile 1: Farbauswahl
ui.label(
egui::RichText::new(t!("FARBE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
if color_editor(
ui,
&HIGHLIGHTER_COLOR_PRESETS,
&mut state.highlighter_color,
&mut state.show_color_picker,
) {
actions.push(UiAction::SetColor(state.highlighter_color));
}
ui.add_space(10.0);
// Zeile 2: Dicken-Presets
ui.label(
egui::RichText::new(t!("DICKEN-PRESETS"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for (preset, label) in HIGHLIGHTER_WIDTH_PRESETS {
let is_sel =
(state.highlighter_stroke_width_mm - preset).abs() < 0.1;
if stroke_preview_button(
ui,
is_sel,
preset,
label,
state.highlighter_color,
)
.clicked()
{
state.highlighter_stroke_width_mm = preset;
actions.push(UiAction::SetStrokeWidthMm(preset));
}
}
});
ui.add_space(10.0);
// Zeile 3: Slider
ui.label(
egui::RichText::new(t!("STUFENLOSE STÄRKE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(2.0);
if ui
.add(
egui::Slider::new(
&mut state.highlighter_stroke_width_mm,
2.0..=16.0,
)
.suffix(" mm")
.step_by(0.5),
)
.changed()
{
actions.push(UiAction::SetStrokeWidthMm(
state.highlighter_stroke_width_mm,
));
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_text_color_menu_open {
if let Some(btn_rect) = text_color_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("text_color_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!(
"Textfarbe {number}",
number = state.active_text_color + 1
))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_text_color_menu_open = false;
}
},
);
});
ui.add_space(8.0);
let slot = state.active_text_color.min(state.text_colors.len() - 1);
let mut color = state.text_colors[slot];
if color_editor(
ui,
&PEN_COLOR_PRESETS,
&mut color,
&mut state.show_color_picker,
) {
state.text_colors[slot] = color;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
} else {
// Die Farbe ist nicht mehr zu sehen (anderes Werkzeug)
state.is_text_color_menu_open = false;
}
}
// 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin
if state.is_more_menu_open {
if let Some(btn_rect) = more_btn_rect {
let (mut pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
pos.y = btn_rect.max.y;
let pivot = match pivot {
egui::Align2::RIGHT_TOP => egui::Align2::RIGHT_BOTTOM,
_ => egui::Align2::LEFT_BOTTOM,
};
let pop_response = egui::Area::new(egui::Id::new("more_menu_popover"))
.fixed_pos(pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
popover_frame()
.show(ui, |ui| {
ui.set_width(260.0);
touch_friendly_spacing(ui);
let section = |ui: &mut egui::Ui, title: &str| {
ui.add_space(8.0);
ui.label(
egui::RichText::new(title)
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(2.0);
};
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!("Weitere Funktionen"))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked()
{
state.is_more_menu_open = false;
}
},
);
});
// 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, t!("DOKUMENT"));
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Als PDF exportieren (Strg+E)")))
.clicked()
{
actions.push(UiAction::ExportPdf);
state.is_more_menu_open = false;
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(t!("Seite als")).color(egui::Color32::from_gray(190)));
for (label, kind, hint) in [
("PNG", PageExportKind::Png, t!("Bild mit Papier und Raster (200 dpi)")),
("PNG transp.", PageExportKind::PngTransparent, t!("Bild mit transparentem Hintergrund, nur Inhalt")),
("SVG", PageExportKind::Svg, t!("Vektorgrafik, z. B. für Inkscape oder LaTeX")),
] {
if ui.button(label).on_hover_text(hint).clicked() {
actions.push(UiAction::ExportPage(kind));
state.is_more_menu_open = false;
}
}
});
if ui
.add_enabled_ui(!state.endless, |ui| {
ui.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen")))
})
.inner
.on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite"))
.clicked()
{
actions.push(UiAction::AddPage);
}
delete_page_buttons(ui, state, notebook, &mut actions);
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("🖼 Bild einfügen … (Strg+I)")))
.on_hover_text(t!("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster"))
.clicked()
{
state.open_image_picker();
}
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
.clicked()
{
state.open_pdf_picker();
}
section(ui, t!("HINTERGRUND DIESER SEITE"));
let current_bg = notebook.current_page().background;
egui::Grid::new("more_menu_backgrounds")
.num_columns(2)
.spacing([6.0, 6.0])
.show(ui, |ui| {
for (i, (bg, label)) in [
(PageBackground::Grid5mm, t!("Kariert")),
(PageBackground::Lined, t!("Liniert")),
(PageBackground::DotGrid, t!("Punkte")),
(PageBackground::Blank, t!("Blanko")),
]
.into_iter()
.enumerate()
{
if ui
.add_sized(
[115.0, 28.0],
egui::SelectableLabel::new(current_bg == bg, label),
)
.clicked()
{
actions.push(UiAction::SetBackground(bg));
}
if i % 2 == 1 {
ui.end_row();
}
}
});
section(ui, t!("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,
t!("Dunkle Stiftfarben aufhellen"),
),
)
.on_hover_text(
t!("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, t!("KAROS, LINIEN UND PUNKTE"));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0);
for pattern in PatternColor::ALL {
if pattern_color_button(
ui,
pattern,
state.paper_theme,
state.pattern_color == pattern,
)
.clicked()
&& state.pattern_color != pattern
{
state.pattern_color = pattern;
actions.push(UiAction::SetPatternColor(pattern));
}
}
});
section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
let mut layout = if state.endless {
NotebookLayout::Endless
} else if state.continuous_scroll {
NotebookLayout::Continuous
} else {
NotebookLayout::Paged
};
if layout_picker(ui, &mut layout, state.endless_switch_possible) {
actions.push(UiAction::SetNotebookLayout(layout));
}
section(ui, t!("ZOOM"));
ui.horizontal(|ui| {
if ui
.add_enabled(state.current_zoom > 0.25, egui::Button::new("🔍 -"))
.on_hover_text(t!("Herauszoomen (Strg+-)"))
.clicked()
{
actions.push(UiAction::ZoomOut);
}
let pct = (state.current_zoom * 100.0).round() as u32;
if ui
.button(format!("{pct} %"))
.on_hover_text(t!("Zurücksetzen (Strg+0)"))
.clicked()
{
actions.push(UiAction::ResetZoom);
}
if ui
.add_enabled(state.current_zoom < 5.0, egui::Button::new("🔍 +"))
.on_hover_text(t!("Heranzoomen (Strg++)"))
.clicked()
{
actions.push(UiAction::ZoomIn);
}
});
section(ui, t!("EINGABE"));
ui.checkbox(
&mut state.touch_draw_enabled,
t!("Mit dem Finger zeichnen"),
)
.on_hover_text(
t!("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));
}
if stylus_buttons_editor(ui, state) {
actions.push(UiAction::SaveStylusButtons);
}
section(ui, t!("WERKZEUGLEISTE"));
ui.horizontal(|ui| {
for (side, label) in
[(SidebarSide::Left, t!("Links")), (SidebarSide::Right, t!("Rechts"))]
{
if ui
.add_sized(
[115.0, 28.0],
egui::SelectableLabel::new(
state.sidebar_side == side,
label,
),
)
.clicked()
&& state.sidebar_side != side
{
state.sidebar_side = side;
actions.push(UiAction::SetSidebarSide(side));
}
}
});
// Zweisprachig, damit man die Auswahl in jeder Sprache findet
section(ui, "SPRACHE / LANGUAGE");
ui.horizontal(|ui| {
let choices = [
(None, t!("System")),
(Some(crate::i18n::Language::German), "Deutsch"),
(Some(crate::i18n::Language::English), "English"),
];
for (language, label) in choices {
if ui
.add_sized(
[75.0, 28.0],
egui::SelectableLabel::new(
state.language == language,
label,
),
)
.on_hover_text(t!("Sprache der Oberfläche"))
.clicked()
&& state.language != language
{
state.language = language;
actions.push(UiAction::SetLanguage(language));
}
}
});
section(ui, t!("STATUS"));
let (dot_color, status_text) = autosave_status_style(autosave_status);
ui.horizontal(|ui| {
let (dot_rect, _) = ui.allocate_exact_size(
egui::vec2(10.0, 10.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(dot_rect.center(), 4.0, dot_color);
ui.label(egui::RichText::new(status_text).color(dot_color));
});
let update_label = match updater.status() {
crate::updater::UpdaterStatus::UpdateAvailable(rel) => {
t!("🚀 Update {version} verfügbar", version = rel.tag_name)
}
crate::updater::UpdaterStatus::Downloading { percent, .. } => {
t!("⏳ Update lädt … {percent} %", percent = format!("{:.0}", percent * 100.0))
}
crate::updater::UpdaterStatus::DownloadFinished { .. } => {
t!("✔ Update bereit").to_string()
}
crate::updater::UpdaterStatus::Checking => {
t!("🔄 Suche nach Updates …").to_string()
}
_ => t!("🔄 Updates & Versionen").to_string(),
};
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(update_label))
.clicked()
{
state.close_overlays();
state.is_update_dialog_open = true;
}
});
})
.response
.rect
});
popover_rect = Some(pop_response.inner);
}
}
// 2c. SEITENAUSWAHL als Kacheln neben dem Seitenknopf
if state.show_page_jump_popup {
if let Some(btn_rect) = page_btn_rect {
let (pos, pivot) = popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("page_picker_popover"))
.fixed_pos(pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
popover_frame()
.show(ui, |ui| {
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
touch_friendly_spacing(ui);
let title = t!("Seiten ({count})", count = notebook.pages.len());
if dialog_header(ui, &title, true) {
state.show_page_jump_popup = false;
}
// Titel der aktuellen Seite
let current = notebook.current_page();
if state.page_title_for != Some(current.id) {
state.page_title_for = Some(current.id);
state.page_title_input = current.title.clone().unwrap_or_default();
}
ui.label(
egui::RichText::new(t!(
"TITEL VON SEITE {number}",
number = notebook.current_page_index + 1
))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
let field = ui.add(
egui::TextEdit::singleline(&mut state.page_title_input)
.hint_text(t!("z. B. Kapitel 2 – Integrale"))
.margin(egui::vec2(8.0, 8.0))
.desired_width(f32::INFINITY),
);
// Bei jeder Änderung übernehmen: schließt sich die Übersicht, geht nichts verloren
if field.changed() {
actions.push(UiAction::SetPageTitle {
page_id: current.id,
title: state.page_title_input.clone(),
});
}
ui.add_space(2.0);
ui.horizontal(|ui| {
for (list, label) in
[(false, t!("🖼 Kacheln")), (true, t!("📑 Inhalt"))]
{
if ui
.add_sized(
[140.0, 30.0],
egui::SelectableLabel::new(
state.page_picker_list == list,
label,
),
)
.clicked()
{
state.page_picker_list = list;
}
}
});
ui.add_space(2.0);
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
if !state.page_picker_list
&& notebook.pages.iter().any(|p| !p.is_loaded)
{
actions.push(UiAction::LoadAllPages);
}
if state.page_picker_list {
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
ui.set_width(ui.available_width());
if let Some(idx) = page_title_list(ui, notebook) {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
});
} else {
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
for (idx, page) in notebook.pages.iter().enumerate()
{
let is_current =
idx == notebook.current_page_index;
if page_thumbnail(
ui,
state,
page,
idx + 1,
is_current,
)
.clicked()
{
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
if (idx + 1) % PAGE_PICKER_COLUMNS == 0 {
ui.end_row();
}
}
});
});
}
ui.add_space(4.0);
if ui
.add_sized(
[ui.available_width(), 36.0],
egui::Button::new(t!("➕ Neue Seite einfügen")),
)
.on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite"))
.clicked()
{
actions.push(UiAction::AddPage);
state.show_page_jump_popup = false;
}
})
.response
.rect
});
popover_rect = Some(pop_response.inner);
}
}
state.popover_rect = popover_rect;
if !state.is_more_menu_open {
state.confirm_delete_page = false;
}
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open
|| state.is_highlighter_menu_open
|| state.is_text_color_menu_open
|| state.is_more_menu_open
|| state.show_page_jump_popup)
&& ctx.input(|i| i.pointer.any_pressed())
{
if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) {
let in_toolbar = state.toolbar_rect.is_some_and(|r| r.contains(pos));
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
if !in_toolbar && !in_popover {
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_text_color_menu_open = false;
state.is_more_menu_open = false;
state.show_page_jump_popup = false;
}
}
}
// 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen)
if state.is_document_menu_open {
let doc_win = dialog_window(t!("Dokumenten-Manager"), state, false)
.show(ctx, |ui| {
ui.set_width(420.0);
touch_friendly_spacing(ui);
if dialog_header(ui, t!("📚 Notizbücher"), true) {
state.is_document_menu_open = false;
}
// Gezeigter Ordner (gibt es ihn nicht mehr, die oberste Ebene)
if !state.notebook_folder.is_empty()
&& !state.library.folders.contains(&state.notebook_folder)
{
state.notebook_folder.clear();
}
let folder = state.notebook_folder.clone();
folder_breadcrumbs(ui, state, &folder);
ui.add_space(6.0);
// Neues Notizbuch oder neuer Ordner hier
ui.horizontal(|ui| {
let text_edit = ui.add(
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
.hint_text(t!("Name …"))
.margin(egui::vec2(8.0, 8.0))
.desired_width(190.0),
);
let create_btn = ui
.button(egui::RichText::new(t!("➕ Notizbuch")).strong())
.on_hover_text(t!("Neues Notizbuch in diesem Ordner anlegen und öffnen"));
let folder_btn = ui
.button(egui::RichText::new(t!("📁 Ordner")).strong())
.on_hover_text(t!("Neuen Ordner hier anlegen (z. B. Semester oder Fach)"));
let title = state.new_notebook_title_input.trim().to_string();
if !title.is_empty() {
if create_btn.clicked()
|| (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
actions.push(UiAction::CreateNotebook {
title,
folder: folder.clone(),
layout: state.new_notebook_layout,
});
state.new_notebook_title_input.clear();
} else if folder_btn.clicked() {
actions.push(UiAction::CreateFolder {
parent: folder.clone(),
name: title,
});
state.new_notebook_title_input.clear();
}
}
});
ui.horizontal(|ui| {
ui.label(t!("Seiten:"));
layout_picker(ui, &mut state.new_notebook_layout, true);
});
ui.add_space(8.0);
ui.separator();
ui.add_space(6.0);
// Zielordner für „Verschieben“ wählen
if let Some(moving) = state.moving.clone() {
move_target_list(ui, state, &moving, &mut actions);
return;
}
let summaries: Vec<NotebookSummary> = state
.available_notebooks
.iter()
.filter(|s| state.library.folder_of(&s.id) == folder)
.cloned()
.collect();
let subfolders: Vec<String> = state
.library
.subfolders(&folder)
.into_iter()
.map(str::to_string)
.collect();
egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| {
for path in &subfolders {
ui.push_id(path, |ui| {
ui.horizontal(|ui| folder_row(ui, state, path, &mut actions))
});
ui.add_space(4.0);
}
if subfolders.is_empty() && summaries.is_empty() {
ui.label(
egui::RichText::new(t!("Dieser Ordner ist leer."))
.color(egui::Color32::from_gray(150)),
);
}
for summary in &summaries {
let is_current = summary.id == state.current_notebook_id;
ui.push_id(&summary.id, |ui| ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
if state.renaming_notebook_id.as_deref() == Some(&summary.id) {
let edit = ui.add(
egui::TextEdit::singleline(&mut state.renaming_title_input)
.margin(egui::vec2(8.0, 8.0))
.desired_width(260.0),
);
if ui.button("✔").clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
let new_t = state.renaming_title_input.trim();
if !new_t.is_empty() {
actions.push(UiAction::RenameNotebook {
id: summary.id.clone(),
new_title: new_t.to_string(),
});
}
state.renaming_notebook_id = None;
}
if ui.button("🗙").clicked() {
state.renaming_notebook_id = None;
}
} else {
let (bg, border) = if is_current {
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 200),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_unmultiplied(110, 165, 255, 180)),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
)
};
// Nur der Titelbereich öffnet das Notizbuch: Eine Klickfläche über der
// ganzen Zeile würde (als zuletzt angelegtes Element) die Klicks auf
// Umbenennen und Löschen abfangen
let mut open_clicked = false;
egui::Frame::none()
.fill(bg)
.stroke(border)
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
// Erst die Knöpfe (rechts), der Titel füllt den Rest: Die ganze
// Titelfläche öffnet das Notizbuch, ohne die Knöpfe zu überdecken
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if summary.status == crate::storage::NotebookStatus::Damaged {
if ui
.button(egui::RichText::new(t!("🔄 Backup")).size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
.on_hover_text(t!("Aus manifest.json.bak wiederherstellen"))
.clicked()
{
actions.push(UiAction::RestoreNotebookBackup(summary.id.clone()));
}
if ui
.button(egui::RichText::new(t!("📁 Ordner")).size(13.0))
.on_hover_text(t!("Pfad in Statusleiste anzeigen"))
.clicked()
{
actions.push(UiAction::OpenNotebookFolder(summary.id.clone()));
}
} else {
if state.available_notebooks.len() > 1
&& ui
.button(egui::RichText::new("🗑").size(16.0).color(egui::Color32::from_rgb(255, 120, 120)))
.on_hover_text(t!("Notizbuch in Papierkorb verschieben"))
.clicked()
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
if ui
.button(egui::RichText::new("📂").size(16.0))
.on_hover_text(t!("In einen anderen Ordner verschieben"))
.clicked()
{
state.moving = Some(MoveSubject::Notebook(summary.id.clone()));
}
if ui.button(egui::RichText::new("✏").size(16.0))
.on_hover_text(t!("Titel umbenennen"))
.clicked()
{
state.renaming_notebook_id = Some(summary.id.clone());
state.renaming_title_input = summary.title.clone();
}
}
let title_area = ui
.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.vertical(|ui| {
ui.set_min_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(&summary.title)
.strong()
.size(13.0)
.color(egui::Color32::WHITE),
);
if summary.status == crate::storage::NotebookStatus::Damaged {
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), t!("⚠ Beschädigt"));
} else if is_current {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), t!("⚫ Aktiv"));
}
});
let subtext = if let Some(ref err) = summary.error {
t!("Fehler: {err}", err = err)
} else {
t!("{count} Seite(n)", count = summary.page_count)
};
ui.label(
egui::RichText::new(subtext)
.size(11.0)
.color(egui::Color32::from_gray(160)),
);
})
})
.inner;
open_clicked = title_area
.response
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked();
});
});
if summary.status != crate::storage::NotebookStatus::Damaged
&& open_clicked
&& !is_current
{
actions.push(UiAction::OpenNotebook(summary.id.clone()));
state.is_document_menu_open = false;
}
}
}));
ui.add_space(4.0);
}
});
if let Some((_, ref title)) = state.last_deleted_notebook {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!("'{title}' gelöscht", title = title))
.size(11.0)
.color(egui::Color32::from_rgb(255, 180, 100)),
);
if ui
.button(egui::RichText::new(t!("↩ Rückgängig")).size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
.clicked()
{
actions.push(UiAction::UndoDeleteNotebook);
}
});
}
});
state.document_modal_rect = doc_win.map(|w| w.response.rect);
} else {
state.document_modal_rect = None;
}
// 3c. Dateiauswahl „Bild einfügen“
if state.file_picker.is_some() {
file_picker_dialog(ctx, state, &mut actions);
}
// 3c. Release- & Update-Center (Gitea Releases)
if state.is_update_dialog_open {
let win = dialog_window(t!("Release- & Update-Center"), state, false)
.show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
if dialog_header(ui, t!("🚀 Updates & Versionen"), true) {
state.is_update_dialog_open = false;
}
// Info über lokale Installation
let current_ver = env!("CARGO_PKG_VERSION");
let runtime_name = updater.runtime().display_name();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(t!("Aktuelle Version:")).color(egui::Color32::from_gray(180)));
ui.label(egui::RichText::new(format!("v{}", current_ver)).strong().color(egui::Color32::WHITE));
ui.label(egui::RichText::new(format!("({})", runtime_name)).color(egui::Color32::from_gray(150)));
});
if !updater.runtime().self_update_allowed() {
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!(
"Diese Version wird über die Software-Verwaltung aktualisiert \
(z. B. GNOME Software oder „flatpak update“)."
))
.color(egui::Color32::from_gray(210)),
);
return;
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(t!("Server:")).color(egui::Color32::from_gray(180)));
ui.label(egui::RichText::new(format!("{}/{}/{}", updater.config().server_url, updater.config().repo_owner, updater.config().repo_name)).color(egui::Color32::from_gray(210)));
});
ui.add_space(10.0);
// Update-Prüfung auslösen
let is_busy = matches!(updater.status(), crate::updater::UpdaterStatus::Checking | crate::updater::UpdaterStatus::Downloading { .. });
ui.horizontal(|ui| {
if ui.add_enabled(!is_busy, egui::Button::new(egui::RichText::new(t!("🔄 Nach Updates suchen")).strong())).clicked() {
actions.push(UiAction::CheckForUpdates);
}
});
ui.add_space(10.0);
ui.separator();
ui.add_space(10.0);
// Statusbereich
match updater.status() {
crate::updater::UpdaterStatus::Idle => {
ui.label(
egui::RichText::new(t!("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen."))
.color(egui::Color32::from_gray(170)),
);
}
crate::updater::UpdaterStatus::Checking => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(egui::RichText::new(t!("Prüfe Gitea-Releases...")).color(egui::Color32::WHITE));
});
}
crate::updater::UpdaterStatus::UpToDate { current_version } => {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔");
ui.label(
egui::RichText::new(t!("ink-notes ist auf dem neuesten Stand (v{version}).", version = current_version))
.color(egui::Color32::from_rgb(46, 204, 113))
.strong(),
);
});
}
crate::updater::UpdaterStatus::Error(err) => {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(231, 76, 60), "⚠");
ui.label(
egui::RichText::new(t!("Abfrage fehlgeschlagen:"))
.color(egui::Color32::from_rgb(231, 76, 60))
.strong(),
);
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(err)
.size(11.5)
.color(egui::Color32::from_gray(190)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(t!("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen."))
.size(11.0)
.italics()
.color(egui::Color32::from_gray(140)),
);
});
}
crate::updater::UpdaterStatus::UpdateAvailable(release) => {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫");
ui.label(
egui::RichText::new(t!("Neue Version verfügbar: {version}", version = release.tag_name))
.strong()
.size(14.0)
.color(egui::Color32::WHITE),
);
});
if !release.name.is_empty() && release.name != release.tag_name {
ui.label(egui::RichText::new(&release.name).italics().color(egui::Color32::from_gray(180)));
}
if !release.body.is_empty() {
ui.add_space(6.0);
ui.label(egui::RichText::new(t!("Änderungen:")).strong().size(11.5).color(egui::Color32::from_gray(170)));
egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| {
ui.label(egui::RichText::new(&release.body).size(11.5).color(egui::Color32::from_gray(210)));
});
}
ui.add_space(10.0);
ui.label(egui::RichText::new(t!("VERFÜGBARE ARTEFAKTE:")).size(10.5).strong().color(egui::Color32::from_gray(150)));
ui.add_space(4.0);
for asset in &release.assets {
let is_deb = asset.name.ends_with(".deb");
let is_appimage = asset.name.ends_with(".AppImage");
let is_recommended = match updater.runtime() {
crate::updater::RuntimeEnvironment::AppImage { .. } => is_appimage,
crate::updater::RuntimeEnvironment::DebPackage { .. } => is_deb,
_ => false,
};
let size_mb = (asset.size as f64) / (1024.0 * 1024.0);
let btn_text = if size_mb > 0.05 {
format!("📥 {} ({:.1} MB)", asset.name, size_mb)
} else {
format!("📥 {}", asset.name)
};
ui.horizontal(|ui| {
let btn = if is_recommended {
ui.button(egui::RichText::new(format!("⭐ {}", btn_text)).strong().color(egui::Color32::from_rgb(110, 195, 255)))
} else {
ui.button(egui::RichText::new(&btn_text))
};
if btn.clicked() {
actions.push(UiAction::DownloadUpdateAsset(asset.clone()));
}
if is_recommended {
ui.label(egui::RichText::new(t!("(Empfohlen für dieses System)")).size(10.5).color(egui::Color32::from_rgb(46, 204, 113)));
}
});
}
ui.add_space(8.0);
if !release.html_url.is_empty()
&& ui.button(egui::RichText::new(t!("🌐 Release auf Gitea öffnen")).size(12.0)).clicked()
{
actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone()));
}
});
}
crate::updater::UpdaterStatus::Downloading { asset_name, percent } => {
ui.vertical(|ui| {
ui.label(egui::RichText::new(t!("Lade '{name}' herunter...", name = asset_name)).strong());
ui.add_space(6.0);
ui.add(egui::ProgressBar::new(*percent).show_percentage());
});
}
crate::updater::UpdaterStatus::DownloadFinished { file_path, asset_name } => {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔");
ui.label(
egui::RichText::new(t!("'{name}' erfolgreich heruntergeladen!", name = asset_name))
.strong()
.color(egui::Color32::from_rgb(46, 204, 113)),
);
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(t!("Gespeichert in: {path}", path = file_path.display()))
.size(11.0)
.color(egui::Color32::from_gray(170)),
);
ui.add_space(8.0);
let action_label = match updater.runtime() {
crate::updater::RuntimeEnvironment::AppImage { .. } => t!("🚀 AppImage aktualisieren & ausführen"),
crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"),
crate::updater::RuntimeEnvironment::PortableOrDev { .. }
| crate::updater::RuntimeEnvironment::Flatpak => t!("📂 Im Dateimanager anzeigen"),
};
ui.horizontal(|ui| {
if ui.button(egui::RichText::new(action_label).strong()).clicked() {
actions.push(UiAction::InstallOrOpenUpdate(file_path.clone()));
}
if ui.button(t!("📂 Ordner öffnen")).clicked() {
if let Some(parent) = file_path.parent() {
actions.push(UiAction::OpenUrlInBrowser(parent.to_string_lossy().to_string()));
}
}
});
});
}
}
ui.add_space(12.0);
ui.separator();
ui.add_space(4.0);
// Erweiterte Konfiguration (Collapsible)
egui::CollapsingHeader::new(egui::RichText::new(t!("⚙ Erweiterte Server-Einstellungen")).size(11.5).color(egui::Color32::from_gray(160)))
.default_open(false)
.show(ui, |ui| {
ui.add_space(4.0);
ui.label(egui::RichText::new(t!("Gitea Server-URL:")).size(11.0));
ui.add(egui::TextEdit::singleline(&mut state.updater_server_url_input).desired_width(380.0));
ui.add_space(4.0);
ui.label(egui::RichText::new(t!("Personal Access Token (optional für private Repos):")).size(11.0));
ui.add(egui::TextEdit::singleline(&mut state.updater_token_input).password(true).desired_width(380.0));
ui.add_space(6.0);
if ui.button(egui::RichText::new(t!("💾 Einstellungen speichern")).size(11.5)).clicked() {
let token = if state.updater_token_input.trim().is_empty() {
None
} else {
Some(state.updater_token_input.trim().to_string())
};
actions.push(UiAction::SaveUpdaterConfig {
server_url: state.updater_server_url_input.trim().to_string(),
auth_token: token,
});
}
});
});
state.update_modal_rect = win.map(|w| w.response.rect);
} else {
state.update_modal_rect = None;
}
// 4. Feedback-Toast für PDF-Export oder wichtige Systemmeldungen
if let Some((msg, timestamp)) = &state.export_message {
let elapsed = timestamp.elapsed().as_secs_f32();
if elapsed < 4.0 {
let alpha = if elapsed > 3.0 {
((4.0 - elapsed) * 255.0) as u8
} else {
255
};
egui::Area::new(egui::Id::new("export_toast_banner"))
.anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -35.0))
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(
20,
25,
30,
alpha.min(235),
))
.rounding(egui::Rounding::same(10.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(46, 204, 113, alpha),
))
.inner_margin(egui::Margin::symmetric(20.0, 10.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 4.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(alpha.min(130)),
})
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!("✔ {}", msg))
.color(egui::Color32::from_rgba_unmultiplied(
220, 255, 230, alpha,
))
.size(13.0)
.strong(),
);
});
});
} else {
state.export_message = None;
}
}
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
if let Some(warning) = notebook.load_warnings.get(&notebook.current_page().id) {
dialog_window(t!("⚠ Datenintegritäts-Warnung"), state, true)
.show(ctx, |ui| {
ui.set_width(420.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
ui.label(
egui::RichText::new(t!(
"Seite {id} konnte nicht fehlerfrei geladen werden!",
id = warning.page_id
))
.strong()
.size(15.0)
.color(egui::Color32::WHITE),
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!("Fehlermeldung: {message}", message = warning.message))
.size(12.0)
.color(egui::Color32::from_gray(200)),
);
ui.add_space(8.0);
if let Some(ref q_path) = warning.quarantine_path {
ui.label(
egui::RichText::new(t!("Die beschädigte Originaldatei wurde in Quarantäne gesichert:"))
.size(12.0)
.color(egui::Color32::from_gray(220)),
);
ui.add_space(4.0);
ui.monospace(
egui::RichText::new(q_path.display().to_string())
.size(11.0)
.color(egui::Color32::from_rgb(255, 215, 0)),
);
ui.add_space(10.0);
ui.label(
egui::RichText::new(
t!("Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\
Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben."),
)
.size(11.5)
.color(egui::Color32::from_gray(180)),
);
ui.add_space(12.0);
if ui
.button(
egui::RichText::new(t!("Als leere Seite bearbeiten (Schreibschutz aufheben)"))
.color(egui::Color32::WHITE)
.size(12.5),
)
.clicked()
{
actions.push(UiAction::UnlockCorruptedPage(warning.page_id));
}
} else {
ui.label(
egui::RichText::new(
t!("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\
Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern."),
)
.size(12.0)
.color(egui::Color32::from_gray(200)),
);
}
});
}
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
if let Some(err) = &state.shutdown_error {
let mut dismiss = false;
dialog_window(t!("⚠ Ungespeicherte Änderungen"), state, true).show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
ui.label(
egui::RichText::new(t!("Fehler beim Beenden"))
.color(egui::Color32::from_rgb(255, 100, 100))
.size(16.0)
.strong(),
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!(
"Ungespeicherte Änderungen konnten nicht gesichert werden:"
))
.size(13.0)
.color(egui::Color32::WHITE),
);
ui.add_space(6.0);
egui::Frame::none()
.fill(egui::Color32::from_black_alpha(100))
.rounding(egui::Rounding::same(6.0))
.inner_margin(egui::Margin::same(8.0))
.show(ui, |ui| {
for msg in &err.messages {
ui.label(
egui::RichText::new(msg)
.size(11.5)
.color(egui::Color32::from_rgb(255, 180, 180)),
);
}
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!("Trotzdem beenden?"))
.size(13.0)
.color(egui::Color32::WHITE),
);
ui.add_space(12.0);
ui.horizontal_wrapped(|ui| {
if ui.button(t!("Abbrechen")).clicked() {
dismiss = true;
}
if ui.button(t!("Wiederholen")).clicked() {
actions.push(UiAction::RetryShutdown);
}
if ui.button(t!("Notfall-Export")).clicked() {
actions.push(UiAction::ExportRecoveryAndExit);
}
if ui
.button(
egui::RichText::new(t!("Trotzdem beenden"))
.color(egui::Color32::from_rgb(255, 100, 100)),
)
.clicked()
{
actions.push(UiAction::ForceExit);
}
});
});
if dismiss {
state.shutdown_error = None;
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ui_state_defaults() {
let state = UiState::default();
assert_eq!(state.active_tool, ActiveTool::Pen);
assert_eq!(state.pen_stroke_width_mm, 0.5);
assert_eq!(state.highlighter_stroke_width_mm, 6.0);
assert_eq!(state.current_stroke_width_mm(), 0.5);
assert!(!state.show_page_jump_popup);
assert!(!state.is_pen_menu_open);
assert!(!state.is_highlighter_menu_open);
assert!(!state.is_document_menu_open);
assert_eq!(state.current_notebook_id, "default");
assert_eq!(state.document_title, "Standard-Notizbuch");
assert!(state.export_message.is_none());
assert!(state.toolbar_rect.is_none());
assert!(state.popover_rect.is_none());
}
#[test]
fn test_active_tool_properties() {
assert!(ActiveTool::Pen.is_drawing());
assert!(ActiveTool::Highlighter.is_drawing());
assert!(!ActiveTool::Eraser.is_drawing());
assert!(!ActiveTool::Select.is_drawing());
assert_eq!(ActiveTool::Pen.name(), "Stift (Pen)");
assert_eq!(ActiveTool::Eraser.name(), "Radierer (Eraser)");
assert_eq!(ActiveTool::Select.name(), "Auswahl / Lasso (Select)");
}
#[test]
fn test_render_ui_generates_shapes_and_rect() {
let ctx = egui::Context::default();
let mut state = UiState::new();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
ctx.begin_frame(egui::RawInput::default());
let actions = render_ui(&ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
let output = ctx.end_frame();
assert!(actions.is_empty());
assert!(!output.shapes.is_empty());
assert!(state.toolbar_rect.is_some());
}
#[test]
fn test_pill_hit_testing() {
let mut state = UiState::new();
state.toolbar_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 16.0),
egui::pos2(800.0, 60.0),
));
// 1. Punkt im Zentrum der Pill -> getroffen
assert!(state.is_point_inside_toolbar(500.0, 38.0));
// 2. Punkt oberhalb der Pill (in der 16 px Lücke zum Fensterrand) -> frei
assert!(!state.is_point_inside_toolbar(500.0, 8.0));
// 3. Punkt weit außerhalb links/rechts -> frei
assert!(!state.is_point_inside_toolbar(50.0, 38.0));
assert!(!state.is_point_inside_toolbar(950.0, 38.0));
// 4. Punkt unterhalb der Pill -> frei
assert!(!state.is_point_inside_toolbar(500.0, 100.0));
// 5. Eckpunkt ganz außen in der Bounding Box (außerhalb des 14 px Kreises) -> frei!
assert!(!state.is_point_inside_toolbar(201.0, 17.0));
}
#[test]
fn test_popover_hit_testing() {
let mut state = UiState::new();
state.toolbar_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 16.0),
egui::pos2(800.0, 60.0),
));
state.popover_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 68.0),
egui::pos2(480.0, 240.0),
));
// Klick in Toolbar -> UI
assert!(state.is_point_in_ui(500.0, 38.0));
// Klick in Popover -> UI
assert!(state.is_point_in_ui(300.0, 120.0));
// Klick außerhalb -> frei für Canvas
assert!(!state.is_point_in_ui(600.0, 150.0));
}
#[test]
fn test_pen_color_presets() {
assert_eq!(PEN_COLOR_PRESETS.len(), 5);
assert_eq!(PEN_COLOR_PRESETS[0].1, "Notiz-Schwarz");
assert_eq!(PEN_COLOR_PRESETS[1].1, "Universitäts-Blau");
assert_eq!(PEN_COLOR_PRESETS[2].1, "Korrektur-Rot");
assert_eq!(PEN_COLOR_PRESETS[3].1, "Tafel-Grün");
assert_eq!(PEN_COLOR_PRESETS[4].1, "Graphit-Grau");
}
#[test]
fn test_pen_width_presets() {
assert_eq!(PEN_WIDTH_PRESETS.len(), 4);
assert_eq!(PEN_WIDTH_PRESETS[0].0, 0.3);
assert_eq!(PEN_WIDTH_PRESETS[1].0, 0.5);
assert_eq!(PEN_WIDTH_PRESETS[2].0, 0.8);
assert_eq!(PEN_WIDTH_PRESETS[3].0, 1.2);
}
#[test]
fn test_document_manager_ui_actions() {
let mut state = UiState::new();
state.is_document_menu_open = true;
state.available_notebooks = vec![
NotebookSummary {
id: "nb-1".to_string(),
title: "Mathematik I".to_string(),
page_count: 3,
modified_at: 100,
status: crate::storage::NotebookStatus::Normal,
error: None,
},
NotebookSummary {
id: "nb-2".to_string(),
title: "Physik II".to_string(),
page_count: 5,
modified_at: 200,
status: crate::storage::NotebookStatus::Normal,
error: None,
},
];
state.current_notebook_id = "nb-1".to_string();
state.document_title = "Mathematik I".to_string();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let ctx = egui::Context::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let _ = super::render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
});
assert!(state.is_document_menu_open);
assert_eq!(state.available_notebooks.len(), 2);
}
#[test]
fn test_updater_modal_ui_actions() {
let mut state = UiState::new();
state.is_update_dialog_open = true;
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let ctx = egui::Context::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let _ = super::render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
});
assert!(state.is_update_dialog_open);
assert!(state.update_modal_rect.is_some());
if let Some(rect) = state.update_modal_rect {
assert!(state.is_point_in_ui(rect.center().x, rect.center().y));
}
}
#[test]
fn test_touch_draw_mode_toggle() {
let mut state = UiState::new();
assert!(!state.touch_draw_enabled); // Default: false = Gesten-Navigation & Zoom
state.touch_draw_enabled = true;
assert!(state.touch_draw_enabled);
}
/// Rendert mehrere Frames bei fester Fenstergröße. Angedockte Areas brauchen bis zu 6 Frames,
/// bis egui ihre Größe vermessen hat und sie korrekt positioniert.
fn render_frames(state: &mut UiState, notebook: &Notebook, frames: usize) -> egui::Context {
render_frames_sized(state, notebook, frames, egui::vec2(1280.0, 850.0))
}
fn render_frames_sized(
state: &mut UiState,
notebook: &Notebook,
frames: usize,
screen: egui::Vec2,
) -> egui::Context {
let ctx = egui::Context::default();
let updater = crate::updater::UpdaterController::new();
for _ in 0..frames {
ctx.begin_frame(egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)),
..Default::default()
});
let _ = render_ui(&ctx, state, notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
}
ctx
}
#[test]
fn test_text_edit_overlay_takes_typing_and_escape() {
let notebook = Notebook::new();
let mut state = UiState::new();
state.active_tool = ActiveTool::Text;
state.page_view = Some(PageView {
origin: egui::pos2(300.0, 50.0),
scale: 0.3,
});
state.text_edit = Some(TextEditing::new_at(
notebook.current_page().id,
glam::Vec2::new(200.0, 300.0),
PAGE_WIDTH,
42.0,
TextFont::Sans,
[0.0, 0.0, 0.0, 1.0],
));
let ctx = egui::Context::default();
let updater = crate::updater::UpdaterController::new();
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
ctx.begin_frame(egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 850.0),
)),
events,
..Default::default()
});
let _ = render_ui(&ctx, state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
};
frame(&mut state, Vec::new());
assert!(ctx.wants_keyboard_input(), "Textfeld hat den Fokus");
frame(&mut state, vec![egui::Event::Text("Hallo".into())]);
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo");
// Zeilenumbruch und Tab (Einrücken) bleiben im Textfeld
frame(
&mut state,
vec![egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
}],
);
frame(
&mut state,
vec![egui::Event::Key {
key: egui::Key::Tab,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
}],
);
frame(&mut state, vec![egui::Event::Text("x".into())]);
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo\n\tx");
frame(
&mut state,
vec![egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
}],
);
let edit = state.text_edit.as_ref().unwrap();
assert!(edit.finish);
assert_eq!(edit.text.content, "Hallo\n\tx");
}
#[test]
fn test_sidebar_is_vertical_and_docked_to_chosen_side() {
let notebook = Notebook::new();
let mut left = UiState::new();
render_frames(&mut left, &notebook, 10);
let r = left.toolbar_rect.unwrap();
assert!(
r.height() > r.width() * 4.0,
"Leiste muss senkrecht sein: {r:?}"
);
assert!(r.width() <= 60.0, "Leiste muss schmal sein: {r:?}");
assert!(
(r.min.x - UiState::SIDEBAR_MARGIN).abs() < 1.0,
"links angedockt: {r:?}"
);
assert!(
(r.min.y - UiState::SIDEBAR_MARGIN).abs() < 1.0,
"oben angedockt: {r:?}"
);
assert!(
r.max.y <= 850.0 && r.min.y >= 0.0,
"passt ins Fenster: {r:?}"
);
assert!((left.sidebar_reserved_width() - (r.width() + 20.0)).abs() < 0.01);
let mut right = UiState::new();
right.sidebar_side = SidebarSide::Right;
render_frames(&mut right, &notebook, 10);
let r = right.toolbar_rect.unwrap();
assert!(
(r.max.x - (1280.0 - UiState::SIDEBAR_MARGIN)).abs() < 1.0,
"rechts angedockt: {r:?}"
);
}
#[test]
fn test_popovers_open_towards_the_page() {
let notebook = Notebook::new();
for side in [SidebarSide::Left, SidebarSide::Right] {
for open in ["pen", "more"] {
let mut state = UiState::new();
state.sidebar_side = side;
match open {
"pen" => state.is_pen_menu_open = true,
_ => state.is_more_menu_open = true,
}
render_frames(&mut state, &notebook, 10);
let bar = state.toolbar_rect.unwrap();
let pop = state.popover_rect.expect("Popover muss offen sein");
match side {
SidebarSide::Left => {
assert!(pop.min.x > bar.max.x, "{open}: {pop:?} / {bar:?}")
}
SidebarSide::Right => {
assert!(pop.max.x < bar.min.x, "{open}: {pop:?} / {bar:?}")
}
}
assert!(
pop.min.y >= 0.0 && pop.max.y <= 850.0,
"{open} im Fenster: {pop:?}"
);
assert!(state.is_point_in_ui(pop.center().x, pop.center().y));
}
}
}
#[test]
fn test_notebook_rename_delete_and_open_are_clickable() {
let notebook = Notebook::new();
let fresh = || {
let mut state = UiState::new();
state.is_document_menu_open = true;
state.available_notebooks = ["default", "zweites"]
.iter()
.map(|id| NotebookSummary {
id: id.to_string(),
title: format!("Notizbuch {id}"),
page_count: 1,
modified_at: 0,
status: Default::default(),
error: None,
})
.collect();
state
};
let mut probe = fresh();
render_frames(&mut probe, &notebook, 10);
let modal = probe.document_modal_rect.expect("Notizbuch-Dialog offen");
let updater = crate::updater::UpdaterController::new();
let (mut deleted, mut renamed, mut opened) = (false, false, false);
let mut y = modal.min.y + 4.0;
while y < modal.max.y && !(deleted && renamed && opened) {
let mut x = modal.min.x + 4.0;
while x < modal.max.x {
let mut state = fresh();
let ctx = render_frames(&mut state, &notebook, 3);
let pos = egui::pos2(x, y);
let mut actions = Vec::new();
for pressed in [true, false] {
ctx.begin_frame(egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 850.0),
)),
events: vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
},
],
..Default::default()
});
actions.extend(render_ui(
&ctx,
&mut state,
&notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
deleted |= actions
.iter()
.any(|a| matches!(a, UiAction::DeleteNotebook(_)));
opened |= actions
.iter()
.any(|a| matches!(a, UiAction::OpenNotebook(_)));
renamed |= state.renaming_notebook_id.is_some();
x += 8.0;
}
y += 6.0;
}
assert!(deleted, "Löschen-Knopf reagiert nicht");
assert!(renamed, "Umbenennen-Knopf reagiert nicht");
assert!(opened, "Titel öffnet das Notizbuch nicht");
}
#[test]
fn test_sidebar_buttons_react_to_raw_pointer_events() {
// Gleicher Weg wie die Stift-Eingabe: PointerMoved + PointerButton in die RawInput
let notebook = Notebook::new();
let mut probe = UiState::new();
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let updater = crate::updater::UpdaterController::new();
let mut found = Vec::new();
let mut y = bar.min.y + 4.0;
while y < bar.max.y {
let mut state = UiState::new();
let ctx = render_frames(&mut state, &notebook, 10);
let pos = egui::pos2(bar.center().x, y);
let screen = Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 850.0),
));
let mut actions = Vec::new();
for (pressed, with_move) in [(true, true), (false, false)] {
let mut events = Vec::new();
if with_move {
events.push(egui::Event::PointerMoved(pos));
}
events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
ctx.begin_frame(egui::RawInput {
screen_rect: screen,
events,
..Default::default()
});
actions.extend(render_ui(
&ctx,
&mut state,
&notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
for a in actions {
if let UiAction::SetTool(t) = a {
if !found.contains(&t) {
found.push(t);
}
}
}
y += 6.0;
}
for tool in [
ActiveTool::Highlighter,
ActiveTool::Shape,
ActiveTool::Eraser,
ActiveTool::Select,
ActiveTool::Text,
] {
assert!(
found.contains(&tool),
"{tool:?} nicht per Klick erreichbar, gefunden: {found:?}"
);
}
}
#[test]
fn test_aspect_lock_button_only_with_selection() {
// Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt
let notebook = Notebook::new();
let toggles = |lock: Option<bool>| {
let mut probe = UiState::new();
probe.active_tool = ActiveTool::Select;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let mut found = 0;
let mut y = bar.min.y + 4.0;
while y < bar.max.y {
let mut state = UiState::new();
state.active_tool = ActiveTool::Select;
state.selection_aspect_lock = lock;
let ctx = render_frames(&mut state, &notebook, 10);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(bar.center().x, y));
if actions.contains(&UiAction::ToggleAspectLock) {
found += 1;
}
y += 4.0;
}
found
};
assert_eq!(toggles(None), 0);
assert!(toggles(Some(true)) > 0);
assert!(toggles(Some(false)) > 0);
}
#[test]
fn test_sidebar_fits_window_height_for_every_tool() {
let notebook = Notebook::new();
for height in [850.0, 768.0] {
for tool in [
ActiveTool::Pen,
ActiveTool::Highlighter,
ActiveTool::Shape,
ActiveTool::Eraser,
ActiveTool::Select,
ActiveTool::Text,
] {
let mut state = UiState::new();
state.active_tool = tool;
render_frames_sized(&mut state, &notebook, 10, egui::vec2(1280.0, height));
let r = state.toolbar_rect.unwrap();
assert!(
r.min.y >= 0.0 && r.max.y <= height,
"{tool:?} bei {height} px Höhe: {r:?}"
);
// Ohne Scrollen: Würde gescrollt, wäre die Leiste genau Maximalhöhe + Rahmen hoch
let max_inner = height - 2.0 * UiState::SIDEBAR_MARGIN - 12.0;
assert!(
r.height() < max_inner + 12.0 - 0.5,
"{tool:?} bei {height} px Höhe muss ohne Scrollen passen: {r:?}"
);
}
}
// Große Fenster behalten die volle Knopfgröße
assert_eq!(
UiState::sidebar_button_size(ActiveTool::Select, 1000.0, true),
40.0
);
}
#[test]
fn test_sidebar_buttons_stay_in_place_when_switching_tools() {
// Gleiche Lage und Höhe = Undo/Redo, Seiten und Menü bleiben beim Werkzeugwechsel stehen
let notebook = Notebook::new();
let mut rects = Vec::new();
for tool in [
ActiveTool::Pen,
ActiveTool::Highlighter,
ActiveTool::Shape,
ActiveTool::Eraser,
] {
let mut state = UiState::new();
state.active_tool = tool;
render_frames(&mut state, &notebook, 10);
rects.push((tool, state.toolbar_rect.unwrap()));
}
let first = rects[0].1;
for (tool, r) in &rects {
assert!(
(r.min.y - first.min.y).abs() < 0.5 && (r.height() - first.height()).abs() < 0.5,
"{tool:?}: {r:?} statt {first:?}"
);
}
// Auswahl: gleiche Knopfgröße (Breite) und Oberkante, nur unten kommen Aktionen dazu.
// Sonst verrutschen die Werkzeugknöpfe und das Blatt verschiebt sich.
let mut state = UiState::new();
state.active_tool = ActiveTool::Select;
render_frames(&mut state, &notebook, 10);
let r = state.toolbar_rect.unwrap();
assert!(
(r.width() - first.width()).abs() < 0.5,
"Auswahl: {r:?} statt {first:?}"
);
assert!(
(r.min.y - first.min.y).abs() < 0.5,
"Auswahl: {r:?} statt {first:?}"
);
assert!(
r.height() > first.height(),
"Auswahl-Aktionen fehlen: {r:?}"
);
}
/// Quelltexte, deren String-Literale in der Oberfläche erscheinen können.
const UI_SOURCES: [(&str, &str); 4] = [
("ui.rs", include_str!("ui.rs")),
("selection.rs", include_str!("selection.rs")),
("main.rs", include_str!("main.rs")),
("file_picker.rs", include_str!("file_picker.rs")),
];
/// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare).
fn string_literal_chars(src: &str) -> Vec<char> {
let mut out = Vec::new();
let mut chars = src.chars().peekable();
let (mut in_str, mut in_comment) = (false, false);
while let Some(c) = chars.next() {
if in_comment {
in_comment = c != '\n';
} else if in_str {
match c {
'\\' => {
chars.next();
}
'"' => in_str = false,
_ => out.push(c),
}
} else if c == '/' && chars.peek() == Some(&'/') {
in_comment = true;
} else if c == '"' {
in_str = true;
}
}
out
}
#[test]
fn test_ruler_drag_moves_and_rotates() {
let ctx = egui::Context::default();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let mut state = UiState::new();
// 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0)
let view = PageView {
origin: egui::pos2(100.0, 0.0),
scale: 0.5,
};
state.page_view = Some(view);
state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0)));
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1600.0, 1000.0),
)),
events,
..Default::default()
};
ctx.begin_frame(input);
let _ = render_ui(&ctx, state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
};
let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
};
// Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen
let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0));
assert!(state.is_point_in_ui(center.x, center.y));
let edge = view.to_screen(glam::Vec2::new(
1000.0,
1000.0 - crate::ruler::RULER_WIDTH / 2.0,
));
assert!(!state.is_point_in_ui(edge.x, edge.y));
// Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen
frame(&mut state, vec![egui::Event::PointerMoved(center)]);
frame(&mut state, vec![press(center, true)]);
let target = center + egui::vec2(0.0, 50.0);
frame(&mut state, vec![egui::Event::PointerMoved(target)]);
assert!(state.ruler_drag.is_some());
frame(&mut state, vec![press(target, false)]);
let moved = state.ruler.unwrap();
assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5);
assert!(state.ruler_drag.is_none());
// Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet)
let handle = view.to_screen(moved.handle_center());
frame(&mut state, vec![egui::Event::PointerMoved(handle)]);
frame(&mut state, vec![press(handle, true)]);
let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0));
frame(&mut state, vec![egui::Event::PointerMoved(diag)]);
frame(&mut state, vec![press(diag, false)]);
assert_eq!(state.ruler.unwrap().display_degrees(), 45);
// Ausblenden
state.toggle_ruler();
assert!(state.ruler.is_none());
}
#[test]
fn test_custom_colors_remember_changes() {
let mut state = UiState::new();
assert!(!state.sync_tool_colors(), "Ausgangszustand ist gespeichert");
// Stift 2 wählen und umfärben: der Stift merkt sich die Farbe
state.select_pen(1);
state.pen_color = [0.5, 0.1, 0.6, 1.0];
assert!(state.sync_tool_colors());
assert_eq!(state.pen_presets[1].color, [0.5, 0.1, 0.6, 1.0]);
state.select_pen(0);
state.select_pen(1);
assert_eq!(state.pen_color, [0.5, 0.1, 0.6, 1.0]);
// Textmarker-Farbe 3 anpassen
state.select_highlighter_color(2);
state.highlighter_color = [0.9, 0.5, 0.1, 0.35];
assert!(state.sync_tool_colors());
assert_eq!(state.highlighter_colors[2], [0.9, 0.5, 0.1, 0.35]);
assert_eq!(state.highlighter_colors[0], DEFAULT_HIGHLIGHTER_COLORS[0]);
assert!(!state.sync_tool_colors());
// Textfarbe gilt auch für das gerade bearbeitete Feld
state.text_edit = Some(TextEditing::new_at(
1,
glam::Vec2::new(100.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0, 0.0, 0.0, 1.0],
));
state.set_text_color([0.2, 0.6, 0.3, 1.0]);
assert_eq!(
state.text_edit.as_ref().unwrap().color,
[0.2, 0.6, 0.3, 1.0]
);
}
#[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
let ctx = egui::Context::default();
ctx.begin_frame(egui::RawInput::default());
let font = egui::FontId::proportional(14.0);
let mut missing = std::collections::BTreeSet::new();
for (name, src) in UI_SOURCES {
for c in string_literal_chars(src) {
if !c.is_ascii() && !ctx.fonts(|f| f.has_glyphs(&font, &c.to_string())) {
missing.insert(format!("{name}: {c} (U+{:04X})", c as u32));
}
}
}
let _ = ctx.end_frame();
assert!(
missing.is_empty(),
"Nicht darstellbare Zeichen: {missing:#?}"
);
}
#[test]
fn test_no_invalid_premultiplied_colors() {
// Bei vormultiplizierten Farben darf kein Farbkanal größer als Alpha sein,
// sonst wird z. B. (255, 255, 255, 8) als fast deckendes Weiß gezeichnet
let mut invalid = Vec::new();
for (name, src) in UI_SOURCES {
for part in src.split("from_rgba_premultiplied(").skip(1) {
let args: Vec<u32> = part
.split(')')
.next()
.unwrap_or_default()
.split(',')
.filter_map(|a| a.trim().parse().ok())
.collect();
if args.len() == 4 && args[..3].iter().any(|&v| v > args[3]) {
invalid.push(format!("{name}: {args:?}"));
}
}
}
assert!(
invalid.is_empty(),
"Ungültige vormultiplizierte Farben: {invalid:#?}"
);
}
#[test]
fn test_canvas_points_are_not_ui() {
// Regression v0.3.0: eine bildschirmfüllende Background-Ebene wurde als UI gewertet,
// dadurch ließ sich auf dem Blatt nicht mehr zeichnen
let notebook = Notebook::new();
for scale in [1.0_f32, 2.0] {
let mut state = UiState::new();
let ctx = render_frames(&mut state, &notebook, 10);
let bar = state.toolbar_rect.unwrap();
let phys = |p: egui::Pos2| (p.x * scale, p.y * scale);
for canvas_pt in [egui::pos2(600.0, 400.0), egui::pos2(1200.0, 800.0)] {
assert!(
!is_screen_point_in_ui(&ctx, &state, phys(canvas_pt), scale),
"Blattpunkt {canvas_pt:?} darf keine UI sein (Skalierung {scale})"
);
}
assert!(
is_screen_point_in_ui(&ctx, &state, phys(bar.center()), scale),
"Leiste muss UI sein (Skalierung {scale})"
);
}
}
/// Simuliert einen Klick (Drücken und Loslassen) über rohe Eingabe-Ereignisse.
fn click_at(
ctx: &egui::Context,
state: &mut UiState,
notebook: &Notebook,
pos: egui::Pos2,
) -> Vec<UiAction> {
let updater = crate::updater::UpdaterController::new();
let screen = Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 850.0),
));
let mut actions = Vec::new();
for pressed in [true, false] {
let mut events = vec![egui::Event::PointerMoved(pos)];
events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
ctx.begin_frame(egui::RawInput {
screen_rect: screen,
events,
..Default::default()
});
actions.extend(render_ui(
ctx,
state,
notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
actions
}
#[test]
fn test_stylus_buttons_in_menu() {
use crate::settings::{StylusAction, StylusButton};
let notebook = Notebook::new();
let mut state = UiState::new();
state.is_more_menu_open = true;
// Ohne gelernte Tasten: nur der Hinweis, keine Aktion
let ctx = egui::Context::default();
let updater = crate::updater::UpdaterController::new();
let shapes = ctx
.run(egui::RawInput::default(), |ctx| {
let _ = render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
})
.shapes
.len();
assert!(shapes > 0);
// Mit zwei Tasten: beide Zeilen, die gedrückte hervorgehoben, ohne Fehler
state.stylus_buttons = vec![
StylusButton {
id: 1,
action: StylusAction::Erase,
},
StylusButton {
id: 2,
action: StylusAction::Select,
},
];
state.pressed_stylus_button = Some(1);
let output = ctx.run(egui::RawInput::default(), |ctx| {
let actions = render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
assert!(!actions.contains(&UiAction::SaveStylusButtons));
});
assert!(!output.shapes.is_empty());
assert_eq!(state.stylus_buttons.len(), 2);
}
#[test]
fn test_long_press_detection() {
let t0 = Instant::now();
let ms = |n| t0 + std::time::Duration::from_millis(n);
let mut press = LongPress::default();
assert_eq!(press.fire(ms(1000)), None);
// Stillhalten (kleines Zittern erlaubt): nach 500 ms genau einmal
press.start(1, (100.0, 100.0), t0);
press.moved(1, (106.0, 103.0), 1.0);
assert_eq!(press.deadline(), Some(ms(500)));
assert_eq!(press.fire(ms(499)), None);
assert_eq!(press.fire(ms(500)), Some((100.0, 100.0)));
assert_eq!(press.fire(ms(900)), None);
// Verschieben bricht ab; bei 2× Skalierung zählt der Abstand in logischen Punkten
press.start(1, (100.0, 100.0), t0);
press.moved(1, (115.0, 100.0), 2.0);
assert!(press.deadline().is_some());
press.moved(1, (125.0, 100.0), 2.0);
assert_eq!(press.fire(ms(600)), None);
// Abheben oder ein anderer Finger brechen ab
press.start(1, (100.0, 100.0), t0);
press.cancel();
assert_eq!(press.fire(ms(600)), None);
press.start(1, (100.0, 100.0), t0);
press.moved(2, (100.0, 100.0), 1.0);
assert_eq!(press.fire(ms(600)), None);
}
#[test]
fn test_context_menu_pastes_at_pressed_point() {
let notebook = Notebook::new();
let mut state = UiState::new();
let at = glam::Vec2::new(800.0, 1200.0);
state.context_menu = Some(ContextMenu {
screen: egui::pos2(600.0, 400.0),
page: at,
});
let ctx = render_frames(&mut state, &notebook, 3);
// Solange es offen ist, gehört jeder Punkt der Oberfläche (Tippen daneben schließt)
assert!(state.is_point_in_ui(1000.0, 700.0));
// „Einfügen“ ist der erste Eintrag
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(700.0, 425.0));
assert!(actions.contains(&UiAction::PasteAt(at)), "{actions:?}");
assert!(state.context_menu.is_none());
// Tippen daneben schließt das Menü ohne Aktion
state.context_menu = Some(ContextMenu {
screen: egui::pos2(600.0, 400.0),
page: at,
});
let ctx = render_frames(&mut state, &notebook, 3);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(1000.0, 700.0));
assert!(state.context_menu.is_none());
assert!(!actions.iter().any(|a| matches!(a, UiAction::PasteAt(_))));
assert!(!state.is_point_in_ui(1000.0, 700.0));
}
#[test]
fn test_only_one_dialog_or_popover_is_open() {
let notebook = Notebook::new();
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 10);
let bar = state.toolbar_rect.unwrap();
// Erster Knopf der Leiste (Notizbücher): Rahmenrand 6 px + halbe Knopfhöhe (≥ 28 px)
let books = egui::pos2(bar.center().x, bar.min.y + 6.0 + 14.0);
let actions = click_at(&ctx, &mut state, &notebook, books);
assert!(
state.is_document_menu_open,
"Dokumenten-Manager muss offen sein"
);
assert!(
!state.show_page_jump_popup,
"Seitenauswahl muss geschlossen sein"
);
assert!(actions
.iter()
.any(|a| matches!(a, UiAction::RefreshNotebooks)));
state.close_overlays();
assert!(!state.is_document_menu_open && !state.show_page_jump_popup);
}
#[test]
fn test_page_picker_jumps_to_every_page() {
let mut notebook = Notebook::new();
for _ in 0..5 {
notebook.add_page(PageBackground::Grid5mm);
}
let mut probe = UiState::new();
probe.show_page_jump_popup = true;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let pop = probe.popover_rect.expect("Seitenauswahl muss offen sein");
assert!(pop.min.x > bar.max.x, "öffnet zur Blattseite hin: {pop:?}");
assert!(
pop.width() < 320.0,
"so breit wie das Kachelraster: {pop:?}"
);
let mut reached = std::collections::BTreeSet::new();
let mut y = pop.min.y + 4.0;
while y < pop.max.y {
let mut x = pop.min.x + 4.0;
while x < pop.max.x {
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 10);
for a in click_at(&ctx, &mut state, &notebook, egui::pos2(x, y)) {
if let UiAction::GoToPage(idx) = a {
reached.insert(idx);
assert!(!state.show_page_jump_popup, "schließt nach der Auswahl");
}
}
x += 14.0;
}
y += 14.0;
}
assert_eq!(reached, (0..6).collect(), "alle Seiten erreichbar");
}
/// Seite mit `strokes` Strichen à `points` Punkten (diagonal über die Seite).
fn page_with_strokes(strokes: usize, points: usize) -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
for i in 0..strokes {
let samples: Vec<crate::document::StylusSample> = (0..points)
.map(|j| crate::document::StylusSample {
position: glam::Vec2::new(
100.0 + j as f32 * 1800.0 / points as f32,
100.0 + i as f32 * 20.0,
),
pressure: 0.5,
timestamp: j as f64 * 0.01,
})
.collect();
page.strokes.push(crate::document::Stroke::new(
i as u64 + 1,
samples,
Vec::new(),
[0.1, 0.1, 0.1, 1.0],
));
}
page
}
fn shape_points(shapes: &[egui::Shape]) -> Vec<egui::Pos2> {
shapes
.iter()
.flat_map(|s| match s {
egui::Shape::Path(p) => p.points.clone(),
egui::Shape::Circle(c) => vec![c.center],
_ => Vec::new(),
})
.collect()
}
#[test]
fn test_page_thumbnail_shapes() {
let rect = egui::Rect::from_min_size(egui::pos2(50.0, 80.0), egui::vec2(66.0, 93.0));
// Leere Seite: nur das weiße Blatt
let empty = Page::new(1, PageBackground::Blank);
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, PaperTheme::White, true);
assert_eq!(shapes.len(), 1 + 3);
let pts = shape_points(&shapes);
assert!(!pts.is_empty());
assert!(
pts.iter().all(|p| rect.contains(*p)),
"Punkte außerhalb der Kachel"
);
// 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, PaperTheme::White, true));
assert!(
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
"{} Punkte statt höchstens {}",
pts.len(),
THUMBNAIL_POINT_BUDGET + 200
);
// Bild: gefüllte Fläche statt Umrisslinie, unter der Tinte
let mut with_image = page_with_strokes(1, 5);
with_image.strokes.insert(
0,
crate::document::Stroke::new_image(
99,
"a.png".into(),
[
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(900.0, 100.0),
glam::Vec2::new(900.0, 600.0),
glam::Vec2::new(100.0, 600.0),
],
),
);
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
assert_eq!(shapes.len(), 3);
assert!(
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
);
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
}
#[test]
fn test_page_picker_requests_loading_of_unloaded_pages() {
let updater = crate::updater::UpdaterController::new();
for (unloaded, expected) in [(false, false), (true, true)] {
let mut notebook = Notebook::new();
notebook.add_page(PageBackground::Grid5mm);
if unloaded {
notebook.pages[1] =
Page::new_unloaded(notebook.pages[1].id, PageBackground::Grid5mm);
}
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 3);
ctx.begin_frame(egui::RawInput::default());
let actions = render_ui(&ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages));
assert_eq!(requested, expected, "ungeladene Seite: {unloaded}");
}
}
}