- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl), Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster - Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren, kopieren, löschen und Undo über die bestehende Auswahl - Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets (JPEG unverändert, sonst PNG); Dateiformat-Version 4 - GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang - PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi - Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte); Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3612 lines
154 KiB
Rust
3612 lines
154 KiB
Rust
//! 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};
|
||
use crate::document::{
|
||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||
};
|
||
use crate::file_picker::FilePicker;
|
||
use crate::settings::{PressureCurve, SidebarSide};
|
||
use crate::storage::NotebookSummary;
|
||
|
||
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ActiveTool {
|
||
Pen,
|
||
Highlighter,
|
||
Eraser,
|
||
Shape,
|
||
Select,
|
||
}
|
||
|
||
impl ActiveTool {
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
ActiveTool::Pen => "Stift (Pen)",
|
||
ActiveTool::Highlighter => "Textmarker (Highlighter)",
|
||
ActiveTool::Eraser => "Radierer (Eraser)",
|
||
ActiveTool::Shape => "Formen (Shape)",
|
||
ActiveTool::Select => "Auswahl / Lasso (Select)",
|
||
}
|
||
}
|
||
|
||
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], "Notiz-Schwarz"),
|
||
([0.15, 0.35, 0.85, 1.0], "Universitäts-Blau"),
|
||
([0.85, 0.20, 0.20, 1.0], "Korrektur-Rot"),
|
||
([0.18, 0.65, 0.35, 1.0], "Tafel-Grün"),
|
||
([0.45, 0.45, 0.48, 1.0], "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], "Neon-Gelb"),
|
||
([0.2, 0.9, 0.3, 0.35], "Hellgrün"),
|
||
([0.25, 0.7, 1.0, 0.35], "Pastell-Cyan"),
|
||
([1.0, 0.3, 0.65, 0.35], "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")];
|
||
|
||
/// 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,
|
||
GoToPage(usize),
|
||
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
|
||
LoadAllPages,
|
||
SetBackground(PageBackground),
|
||
ExportPdf,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
ResetZoom,
|
||
DeleteSelected,
|
||
CopySelected,
|
||
PasteSelected,
|
||
RotateSelected,
|
||
// Dokumenten- & Notizbuch-Verwaltung
|
||
OpenNotebook(String),
|
||
CreateNotebook(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),
|
||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||
SetPaperTheme {
|
||
theme: PaperTheme,
|
||
adapt_ink: bool,
|
||
},
|
||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||
SavePressureCurve(PressureCurve),
|
||
/// Bilddatei auf der aktuellen Seite einfügen
|
||
InsertImageFile(std::path::PathBuf),
|
||
// 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,
|
||
pub renaming_notebook_id: Option<String>,
|
||
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)>,
|
||
/// Fehler beim Flushen vor dem Beenden
|
||
pub shutdown_error: Option<crate::autosave::AutosaveError>,
|
||
/// Fensterseite der Werkzeugleiste
|
||
pub sidebar_side: SidebarSide,
|
||
/// Öffnungszustand des ☰-Menüs
|
||
pub is_more_menu_open: bool,
|
||
/// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste)
|
||
pub eraser_mode: EraserMode,
|
||
/// Papierfarbe der Anzeige
|
||
pub paper_theme: PaperTheme,
|
||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||
pub adapt_ink_colors: bool,
|
||
/// Druckkurve des Stifts
|
||
pub pressure_curve: PressureCurve,
|
||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||
pub last_raw_pressure: Option<f32>,
|
||
/// Geöffnete Dateiauswahl „Bild einfügen“
|
||
pub image_picker: Option<FilePicker>,
|
||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||
pub last_image_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: "Standard-Notizbuch".to_string(),
|
||
available_notebooks: Vec::new(),
|
||
new_notebook_title_input: String::new(),
|
||
renaming_notebook_id: 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,
|
||
shutdown_error: None,
|
||
sidebar_side: SidebarSide::Left,
|
||
is_more_menu_open: false,
|
||
eraser_mode: EraserMode::All,
|
||
paper_theme: PaperTheme::White,
|
||
adapt_ink_colors: true,
|
||
pressure_curve: PressureCurve::default(),
|
||
last_raw_pressure: None,
|
||
image_picker: None,
|
||
last_image_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.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.image_picker = None;
|
||
}
|
||
|
||
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||
pub fn open_image_picker(&mut self) {
|
||
let dir = self.last_image_dir.clone();
|
||
self.close_overlays();
|
||
self.image_picker = Some(FilePicker::open(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.
|
||
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 {
|
||
let select = tool == ActiveTool::Select;
|
||
// Notizbücher, 5 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen)
|
||
let buttons = 12 + 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()
|
||
}
|
||
|
||
/// 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::Eraser => [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 => self.pen_stroke_width_mm,
|
||
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
|
||
ActiveTool::Eraser => 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 {
|
||
if self.is_point_inside_toolbar(px, py) {
|
||
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(format!("Stärke auf {} setzen", 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,
|
||
}
|
||
|
||
/// 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::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), "Alles gespeichert"),
|
||
AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert …"),
|
||
AutosaveStatus::Pending => (
|
||
egui::Color32::from_rgb(230, 126, 34),
|
||
"Ungespeicherte Änderungen",
|
||
),
|
||
AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), "Speicherfehler!"),
|
||
}
|
||
}
|
||
|
||
/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser.
|
||
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` (A4-Format) in der
|
||
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten
|
||
/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
|
||
pub fn page_thumbnail_shapes(
|
||
page: &Page,
|
||
rect: egui::Rect,
|
||
paper: PaperTheme,
|
||
adapt_ink: bool,
|
||
) -> Vec<egui::Shape> {
|
||
let mut shapes = vec![egui::Shape::rect_filled(
|
||
rect,
|
||
egui::Rounding::same(3.0),
|
||
linear_color32(paper.paper_color()),
|
||
)];
|
||
let adapt = adapt_ink && paper.is_dark();
|
||
let scale = rect.width() / PAGE_WIDTH;
|
||
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
||
|
||
// 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);
|
||
|
||
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
|
||
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
|
||
}
|
||
|
||
/// 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),
|
||
);
|
||
}
|
||
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)
|
||
},
|
||
);
|
||
}
|
||
response.on_hover_text(format!("Seite {number}"))
|
||
}
|
||
|
||
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
|
||
let response = ui.add_sized(
|
||
[115.0, 28.0],
|
||
egui::SelectableLabel::new(selected, format!(" {}", theme.label())),
|
||
);
|
||
let swatch = egui::Rect::from_center_size(
|
||
egui::pos2(response.rect.min.x + 14.0, response.rect.center().y),
|
||
egui::vec2(14.0, 14.0),
|
||
);
|
||
ui.painter().rect_filled(
|
||
swatch,
|
||
egui::Rounding::same(3.0),
|
||
linear_color32(theme.paper_color()),
|
||
);
|
||
ui.painter().rect_stroke(
|
||
swatch,
|
||
egui::Rounding::same(3.0),
|
||
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)),
|
||
);
|
||
response
|
||
}
|
||
|
||
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
|
||
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
|
||
/// (Regler losgelassen oder per Tastatur geändert).
|
||
fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<PressureCurve> {
|
||
let mut save = false;
|
||
let mut track = |response: egui::Response| {
|
||
if response.drag_stopped() || (response.changed() && !response.dragged()) {
|
||
save = true;
|
||
}
|
||
};
|
||
|
||
ui.add_space(4.0);
|
||
ui.label("Druckkurve des Stifts");
|
||
let (graph, _) =
|
||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), egui::Sense::hover());
|
||
let painter = ui.painter_at(graph);
|
||
painter.rect_filled(
|
||
graph,
|
||
egui::Rounding::same(4.0),
|
||
egui::Color32::from_black_alpha(90),
|
||
);
|
||
let to_screen = |raw: f32, out: f32| {
|
||
egui::pos2(
|
||
graph.min.x + 6.0 + raw * (graph.width() - 12.0),
|
||
graph.max.y - 6.0 - out * (graph.height() - 12.0),
|
||
)
|
||
};
|
||
painter.line_segment(
|
||
[to_screen(0.0, 0.0), to_screen(1.0, 1.0)],
|
||
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)),
|
||
);
|
||
let curve = state.pressure_curve;
|
||
let points: Vec<egui::Pos2> = (0..=48)
|
||
.map(|i| {
|
||
let raw = i as f32 / 48.0;
|
||
to_screen(raw, curve.apply(raw))
|
||
})
|
||
.collect();
|
||
painter.add(egui::Shape::line(
|
||
points,
|
||
egui::Stroke::new(2.0_f32, egui::Color32::from_rgb(110, 165, 255)),
|
||
));
|
||
if let Some(raw) = state.last_raw_pressure {
|
||
let raw = raw.clamp(0.0, 1.0);
|
||
painter.circle_filled(
|
||
to_screen(raw, curve.apply(raw)),
|
||
4.0,
|
||
egui::Color32::from_rgb(255, 200, 80),
|
||
);
|
||
}
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("weich");
|
||
ui.spacing_mut().slider_width = (ui.available_width() - 40.0).max(60.0);
|
||
track(ui.add(
|
||
egui::Slider::new(&mut state.pressure_curve.exponent, PressureCurve::EXPONENT_RANGE)
|
||
.logarithmic(true)
|
||
.show_value(false),
|
||
).on_hover_text("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien."));
|
||
ui.label("hart");
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mindestdruck");
|
||
ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0);
|
||
track(ui.add(
|
||
egui::Slider::new(&mut state.pressure_curve.threshold, PressureCurve::THRESHOLD_RANGE)
|
||
.custom_formatter(|v, _| format!("{:.0} %", v * 100.0))
|
||
.custom_parser(|s| s.trim().trim_end_matches('%').trim().parse::<f64>().ok().map(|v| v / 100.0)),
|
||
).on_hover_text("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden."));
|
||
});
|
||
if ui
|
||
.add_enabled(
|
||
state.pressure_curve != PressureCurve::default(),
|
||
egui::Button::new("Druckkurve zurücksetzen"),
|
||
)
|
||
.clicked()
|
||
{
|
||
state.pressure_curve = PressureCurve::default();
|
||
save = true;
|
||
}
|
||
save.then_some(state.pressure_curve)
|
||
}
|
||
|
||
/// Dialog „Bild einfügen“: Schnellzugriffe, Ordnerpfad und Liste aus Ordnern und Bildern.
|
||
fn image_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.image_picker.as_ref() else {
|
||
return;
|
||
};
|
||
dialog_window("Bild einfügen", state, false).show(ctx, |ui| {
|
||
ui.set_width(460.0);
|
||
touch_friendly_spacing(ui);
|
||
if dialog_header(ui, "🖼 Bild einfügen", true) {
|
||
close = true;
|
||
}
|
||
|
||
ui.horizontal_wrapped(|ui| {
|
||
for (label, path) in crate::file_picker::shortcuts() {
|
||
if ui.selectable_label(picker.dir == path, 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("Ü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("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)")
|
||
.color(egui::Color32::from_gray(150)),
|
||
);
|
||
}
|
||
for entry in &picker.entries {
|
||
let label = if entry.is_dir {
|
||
format!("📁 {}", entry.name)
|
||
} else {
|
||
format!("🖼 {}", 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(
|
||
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
|
||
)
|
||
.size(11.5)
|
||
.color(egui::Color32::from_gray(150)),
|
||
);
|
||
});
|
||
|
||
if let Some(picker) = state.image_picker.as_mut() {
|
||
if go_up {
|
||
picker.go_up();
|
||
}
|
||
if let Some(dir) = navigate {
|
||
picker.navigate(&dir);
|
||
}
|
||
}
|
||
if let Some(path) = chosen {
|
||
state.last_image_dir = state.image_picker.as_ref().map(|p| p.dir.clone());
|
||
actions.push(UiAction::InsertImageFile(path));
|
||
close = true;
|
||
}
|
||
if close {
|
||
state.image_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("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 = Vec::new();
|
||
|
||
let mut pen_btn_rect = None;
|
||
let mut high_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);
|
||
|
||
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 = format!(
|
||
"Notizbücher verwalten (aktuell: {})",
|
||
if state.document_title.is_empty() {
|
||
"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 {
|
||
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 {
|
||
"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 {
|
||
"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("📐"),
|
||
"Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)",
|
||
),
|
||
(
|
||
ActiveTool::Eraser,
|
||
SidebarIcon::Eraser,
|
||
"Radierer (E oder rechte Maustaste) – darunter: alles oder nur Textmarker",
|
||
),
|
||
(
|
||
ActiveTool::Select,
|
||
SidebarIcon::Lasso,
|
||
"Auswählen: verschieben, drehen, skalieren, kopieren (V)",
|
||
),
|
||
] {
|
||
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));
|
||
}
|
||
}
|
||
|
||
// --- Farben des aktiven Werkzeugs ---
|
||
let palette: Option<(&[ColorPreset], [f32; 4])> =
|
||
match state.active_tool {
|
||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => {
|
||
Some((&PEN_COLOR_PRESETS[..], state.pen_color))
|
||
}
|
||
ActiveTool::Highlighter => Some((
|
||
&HIGHLIGHTER_COLOR_PRESETS[..],
|
||
state.highlighter_color,
|
||
)),
|
||
ActiveTool::Eraser => None,
|
||
};
|
||
// 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 shown = palette.map_or(0, |(presets, _)| presets.len());
|
||
if let Some((presets, current)) = palette {
|
||
for &(color, label) in presets {
|
||
let is_sel = (current[0] - color[0]).abs() < 0.05
|
||
&& (current[1] - color[1]).abs() < 0.05
|
||
&& (current[2] - color[2]).abs() < 0.05;
|
||
if color_circle_swatch(ui, color, is_sel, label, swatch)
|
||
.clicked()
|
||
{
|
||
if state.active_tool == ActiveTool::Highlighter {
|
||
state.highlighter_color = color;
|
||
} else {
|
||
state.pen_color = 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("Alle"),
|
||
None,
|
||
"Alle Striche radieren",
|
||
),
|
||
(
|
||
EraserMode::HighlighterOnly,
|
||
SidebarIcon::Glyph("🖊"),
|
||
Some(state.highlighter_color),
|
||
"Nur Textmarker radieren – Handschrift darunter bleibt",
|
||
),
|
||
] {
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
icon,
|
||
state.eraser_mode == mode,
|
||
true,
|
||
indicator,
|
||
tooltip,
|
||
)
|
||
.clicked()
|
||
{
|
||
state.eraser_mode = mode;
|
||
}
|
||
used += btn + spacing;
|
||
}
|
||
}
|
||
let reserved =
|
||
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
|
||
if reserved > used {
|
||
ui.add_space(reserved - used);
|
||
}
|
||
|
||
// --- Aktionen für die Auswahl ---
|
||
if state.active_tool == ActiveTool::Select {
|
||
sidebar_divider(ui, btn);
|
||
for (icon, tooltip, action) in [
|
||
("📋", "Kopieren (Strg+C)", UiAction::CopySelected),
|
||
("📥", "Einfügen (Strg+V)", UiAction::PasteSelected),
|
||
("🔄", "Um 90° drehen (R)", UiAction::RotateSelected),
|
||
("🗑", "Löschen (Entf)", UiAction::DeleteSelected),
|
||
] {
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
SidebarIcon::Glyph(icon),
|
||
false,
|
||
true,
|
||
None,
|
||
tooltip,
|
||
)
|
||
.clicked()
|
||
{
|
||
actions.push(action);
|
||
}
|
||
}
|
||
}
|
||
|
||
sidebar_divider(ui, btn);
|
||
|
||
// --- Undo & Redo ---
|
||
let history = ¬ebook.current_page().history;
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
SidebarIcon::Glyph("⟲"),
|
||
false,
|
||
history.can_undo(),
|
||
None,
|
||
"Rückgängig (Strg+Z)",
|
||
)
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::Undo);
|
||
}
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
SidebarIcon::Glyph("⟳"),
|
||
false,
|
||
history.can_redo(),
|
||
None,
|
||
"Wiederholen (Strg+Y)",
|
||
)
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::Redo);
|
||
}
|
||
|
||
sidebar_divider(ui, btn);
|
||
|
||
// --- Seitennavigation ---
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
SidebarIcon::Glyph("⏶"),
|
||
false,
|
||
notebook.current_page_index > 0,
|
||
None,
|
||
"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,
|
||
true,
|
||
None,
|
||
"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;
|
||
}
|
||
let is_last = notebook.current_page_index + 1 >= notebook.pages.len();
|
||
if sidebar_button(
|
||
ui,
|
||
btn,
|
||
SidebarIcon::Glyph("⏷"),
|
||
false,
|
||
true,
|
||
None,
|
||
if is_last {
|
||
"Neue Seite anhängen (Bild ab / N)"
|
||
} else {
|
||
"Nächste Seite (Bild ab / N)"
|
||
},
|
||
)
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::NextPage);
|
||
}
|
||
|
||
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 {
|
||
"Weitere Funktionen – Update verfügbar"
|
||
} else {
|
||
"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("Stift-Konfiguration")
|
||
.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("FARBE")
|
||
.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(8.0, 0.0);
|
||
for (color, label) in PEN_COLOR_PRESETS {
|
||
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05
|
||
&& (state.pen_color[1] - color[1]).abs() < 0.05
|
||
&& (state.pen_color[2] - color[2]).abs() < 0.05;
|
||
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() {
|
||
state.pen_color = color;
|
||
actions.push(UiAction::SetColor(color));
|
||
}
|
||
}
|
||
|
||
// RGBA-Colorpicker
|
||
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
|
||
state.pen_color[0],
|
||
state.pen_color[1],
|
||
state.pen_color[2],
|
||
state.pen_color[3],
|
||
);
|
||
if egui::color_picker::color_edit_button_rgba(
|
||
ui,
|
||
&mut egui_color,
|
||
egui::color_picker::Alpha::Opaque,
|
||
)
|
||
.changed()
|
||
{
|
||
state.pen_color = egui_color.to_array();
|
||
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("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("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("Textmarker-Konfiguration")
|
||
.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("MARKER-FARBE")
|
||
.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(8.0, 0.0);
|
||
for (color, label) in HIGHLIGHTER_COLOR_PRESETS {
|
||
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05
|
||
&& (state.highlighter_color[1] - color[1]).abs() < 0.05
|
||
&& (state.highlighter_color[2] - color[2]).abs() < 0.05;
|
||
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() {
|
||
state.highlighter_color = color;
|
||
actions.push(UiAction::SetColor(color));
|
||
}
|
||
}
|
||
|
||
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
|
||
state.highlighter_color[0],
|
||
state.highlighter_color[1],
|
||
state.highlighter_color[2],
|
||
state.highlighter_color[3],
|
||
);
|
||
if egui::color_picker::color_edit_button_rgba(
|
||
ui,
|
||
&mut egui_color,
|
||
egui::color_picker::Alpha::OnlyBlend,
|
||
)
|
||
.changed()
|
||
{
|
||
state.highlighter_color = egui_color.to_array();
|
||
actions.push(UiAction::SetColor(state.highlighter_color));
|
||
}
|
||
});
|
||
|
||
ui.add_space(10.0);
|
||
|
||
// Zeile 2: Dicken-Presets
|
||
ui.label(
|
||
egui::RichText::new("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("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);
|
||
}
|
||
}
|
||
|
||
// 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("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, "DOKUMENT");
|
||
if ui
|
||
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)"))
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::ExportPdf);
|
||
state.is_more_menu_open = false;
|
||
}
|
||
if ui
|
||
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen"))
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::AddPage);
|
||
}
|
||
if ui
|
||
.add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)"))
|
||
.on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")
|
||
.clicked()
|
||
{
|
||
state.open_image_picker();
|
||
}
|
||
|
||
section(ui, "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, "Kariert"),
|
||
(PageBackground::Lined, "Liniert"),
|
||
(PageBackground::DotGrid, "Punkte"),
|
||
(PageBackground::Blank, "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, "PAPIERFARBE (NUR ANZEIGE)");
|
||
let mut paper_changed = false;
|
||
egui::Grid::new("more_menu_paper")
|
||
.num_columns(2)
|
||
.spacing([6.0, 6.0])
|
||
.show(ui, |ui| {
|
||
for (i, theme) in PaperTheme::ALL.into_iter().enumerate() {
|
||
if paper_theme_button(ui, theme, state.paper_theme == theme)
|
||
.clicked()
|
||
&& state.paper_theme != theme
|
||
{
|
||
state.paper_theme = theme;
|
||
paper_changed = true;
|
||
}
|
||
if i % 2 == 1 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
if ui
|
||
.add_enabled(
|
||
state.paper_theme.is_dark(),
|
||
egui::Checkbox::new(
|
||
&mut state.adapt_ink_colors,
|
||
"Dunkle Stiftfarben aufhellen",
|
||
),
|
||
)
|
||
.on_hover_text(
|
||
"Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \
|
||
als PDF exportiert werden immer die Originalfarben.",
|
||
)
|
||
.changed()
|
||
{
|
||
paper_changed = true;
|
||
}
|
||
if paper_changed {
|
||
actions.push(UiAction::SetPaperTheme {
|
||
theme: state.paper_theme,
|
||
adapt_ink: state.adapt_ink_colors,
|
||
});
|
||
}
|
||
|
||
section(ui, "ZOOM");
|
||
ui.horizontal(|ui| {
|
||
if ui
|
||
.add_enabled(state.current_zoom > 0.25, egui::Button::new("🔍 -"))
|
||
.on_hover_text("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("Zurücksetzen (Strg+0)")
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::ResetZoom);
|
||
}
|
||
if ui
|
||
.add_enabled(state.current_zoom < 5.0, egui::Button::new("🔍 +"))
|
||
.on_hover_text("Heranzoomen (Strg++)")
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::ZoomIn);
|
||
}
|
||
});
|
||
|
||
section(ui, "EINGABE");
|
||
ui.checkbox(
|
||
&mut state.touch_draw_enabled,
|
||
"Mit dem Finger zeichnen",
|
||
)
|
||
.on_hover_text(
|
||
"Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.",
|
||
);
|
||
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||
actions.push(UiAction::SavePressureCurve(curve));
|
||
}
|
||
|
||
section(ui, "WERKZEUGLEISTE");
|
||
ui.horizontal(|ui| {
|
||
for (side, label) in
|
||
[(SidebarSide::Left, "Links"), (SidebarSide::Right, "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));
|
||
}
|
||
}
|
||
});
|
||
|
||
section(ui, "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) => {
|
||
format!("🚀 Update {} verfügbar", rel.tag_name)
|
||
}
|
||
crate::updater::UpdaterStatus::Downloading { percent, .. } => {
|
||
format!("⏳ Update lädt … {:.0} %", percent * 100.0)
|
||
}
|
||
crate::updater::UpdaterStatus::DownloadFinished { .. } => {
|
||
"✔ Update bereit".to_string()
|
||
}
|
||
crate::updater::UpdaterStatus::Checking => {
|
||
"🔄 Suche nach Updates …".to_string()
|
||
}
|
||
_ => "🔄 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 = format!("Seiten ({})", notebook.pages.len());
|
||
if dialog_header(ui, &title, true) {
|
||
state.show_page_jump_popup = false;
|
||
}
|
||
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
|
||
if notebook.pages.iter().any(|p| !p.is_loaded) {
|
||
actions.push(UiAction::LoadAllPages);
|
||
}
|
||
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("➕ Neue Seite anhängen"),
|
||
)
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::AddPage);
|
||
state.show_page_jump_popup = false;
|
||
}
|
||
})
|
||
.response
|
||
.rect
|
||
});
|
||
popover_rect = Some(pop_response.inner);
|
||
}
|
||
}
|
||
|
||
state.popover_rect = popover_rect;
|
||
|
||
// 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_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_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("Dokumenten-Manager", state, false)
|
||
.show(ctx, |ui| {
|
||
ui.set_width(420.0);
|
||
touch_friendly_spacing(ui);
|
||
if dialog_header(ui, "📚 Notizbücher", true) {
|
||
state.is_document_menu_open = false;
|
||
}
|
||
|
||
// Neues Dokument anlegen
|
||
ui.label(
|
||
egui::RichText::new("NEUES NOTIZBUCH ERSTELLEN")
|
||
.size(10.5)
|
||
.strong()
|
||
.color(egui::Color32::from_gray(160)),
|
||
);
|
||
ui.add_space(4.0);
|
||
ui.horizontal(|ui| {
|
||
let text_edit = ui.add(
|
||
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
|
||
.hint_text("Titel des neuen Notizbuchs …")
|
||
.margin(egui::vec2(8.0, 8.0))
|
||
.desired_width(270.0),
|
||
);
|
||
let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong());
|
||
let submit = create_btn.clicked()
|
||
|| (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
|
||
|
||
if submit {
|
||
let title = state.new_notebook_title_input.trim();
|
||
if !title.is_empty() {
|
||
actions.push(UiAction::CreateNotebook(title.to_string()));
|
||
state.new_notebook_title_input.clear();
|
||
}
|
||
}
|
||
});
|
||
|
||
ui.add_space(12.0);
|
||
ui.separator();
|
||
ui.add_space(8.0);
|
||
|
||
// Liste vorhandener Dokumente
|
||
ui.label(
|
||
egui::RichText::new("VORHANDENE DOKUMENTE")
|
||
.size(10.5)
|
||
.strong()
|
||
.color(egui::Color32::from_gray(160)),
|
||
);
|
||
ui.add_space(4.0);
|
||
|
||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||
for summary in &state.available_notebooks {
|
||
let is_current = summary.id == state.current_notebook_id;
|
||
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)),
|
||
)
|
||
};
|
||
|
||
let frame_resp = egui::Frame::none()
|
||
.fill(bg)
|
||
.stroke(border)
|
||
.rounding(egui::Rounding::same(8.0))
|
||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||
.show(ui, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.vertical(|ui| {
|
||
ui.horizontal(|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), "⚠ Beschädigt");
|
||
} else if is_current {
|
||
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv");
|
||
}
|
||
});
|
||
let subtext = if let Some(ref err) = summary.error {
|
||
format!("Fehler: {err}")
|
||
} else {
|
||
format!("{} Seite(n)", summary.page_count)
|
||
};
|
||
ui.label(
|
||
egui::RichText::new(subtext)
|
||
.size(11.0)
|
||
.color(egui::Color32::from_gray(160)),
|
||
);
|
||
});
|
||
|
||
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("🔄 Backup").size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
|
||
.on_hover_text("Aus manifest.json.bak wiederherstellen")
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::RestoreNotebookBackup(summary.id.clone()));
|
||
}
|
||
if ui
|
||
.button(egui::RichText::new("📁 Ordner").size(13.0))
|
||
.on_hover_text("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("Notizbuch in Papierkorb verschieben")
|
||
.clicked()
|
||
{
|
||
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
|
||
}
|
||
|
||
if ui.button(egui::RichText::new("✏").size(16.0))
|
||
.on_hover_text("Titel umbenennen")
|
||
.clicked()
|
||
{
|
||
state.renaming_notebook_id = Some(summary.id.clone());
|
||
state.renaming_title_input = summary.title.clone();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
if summary.status != crate::storage::NotebookStatus::Damaged
|
||
&& frame_resp.response.interact(egui::Sense::click()).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(format!("'{title}' gelöscht"))
|
||
.size(11.0)
|
||
.color(egui::Color32::from_rgb(255, 180, 100)),
|
||
);
|
||
if ui
|
||
.button(egui::RichText::new("↩ 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.image_picker.is_some() {
|
||
image_picker_dialog(ctx, state, &mut actions);
|
||
}
|
||
|
||
// 3c. Release- & Update-Center (Gitea Releases)
|
||
if state.is_update_dialog_open {
|
||
let win = dialog_window("Release- & Update-Center", state, false)
|
||
.show(ctx, |ui| {
|
||
ui.set_width(460.0);
|
||
touch_friendly_spacing(ui);
|
||
if dialog_header(ui, "🚀 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("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)));
|
||
});
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label(egui::RichText::new("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("🔄 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("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("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(format!("ink-notes ist auf dem neuesten Stand (v{}).", 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("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("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(format!("Neue Version verfügbar: {}", 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("Ä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("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("(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("🌐 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(format!("Lade '{}' herunter...", 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(format!("'{}' erfolgreich heruntergeladen!", asset_name))
|
||
.strong()
|
||
.color(egui::Color32::from_rgb(46, 204, 113)),
|
||
);
|
||
});
|
||
ui.add_space(4.0);
|
||
ui.label(
|
||
egui::RichText::new(format!("Gespeichert in: {}", 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 { .. } => "🚀 AppImage aktualisieren & ausführen",
|
||
crate::updater::RuntimeEnvironment::DebPackage { .. } => "📦 Paket mit Software-Center öffnen / installieren",
|
||
crate::updater::RuntimeEnvironment::PortableOrDev { .. } => "📂 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("📂 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("⚙ 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("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("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("💾 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(¬ebook.current_page().id) {
|
||
dialog_window("⚠ 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(format!(
|
||
"Seite {} konnte nicht fehlerfrei geladen werden!",
|
||
warning.page_id
|
||
))
|
||
.strong()
|
||
.size(15.0)
|
||
.color(egui::Color32::WHITE),
|
||
);
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.label(
|
||
egui::RichText::new(format!("Fehlermeldung: {}", 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("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(
|
||
"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("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(
|
||
"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("⚠ 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("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("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("Trotzdem beenden?")
|
||
.size(13.0)
|
||
.color(egui::Color32::WHITE),
|
||
);
|
||
ui.add_space(12.0);
|
||
ui.horizontal_wrapped(|ui| {
|
||
if ui.button("Abbrechen").clicked() {
|
||
dismiss = true;
|
||
}
|
||
if ui.button("Wiederholen").clicked() {
|
||
actions.push(UiAction::RetryShutdown);
|
||
}
|
||
if ui.button("Notfall-Export").clicked() {
|
||
actions.push(UiAction::ExportRecoveryAndExit);
|
||
}
|
||
if ui
|
||
.button(
|
||
egui::RichText::new("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, ¬ebook, 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, ¬ebook, 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, ¬ebook, 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_sidebar_is_vertical_and_docked_to_chosen_side() {
|
||
let notebook = Notebook::new();
|
||
|
||
let mut left = UiState::new();
|
||
render_frames(&mut left, ¬ebook, 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, ¬ebook, 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, ¬ebook, 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_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, ¬ebook, 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, ¬ebook, 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,
|
||
¬ebook,
|
||
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,
|
||
] {
|
||
assert!(
|
||
found.contains(&tool),
|
||
"{tool:?} nicht per Klick erreichbar, gefunden: {found:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
] {
|
||
let mut state = UiState::new();
|
||
state.active_tool = tool;
|
||
render_frames_sized(&mut state, ¬ebook, 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),
|
||
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, ¬ebook, 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, ¬ebook, 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_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, ¬ebook, 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_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, ¬ebook, 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, ¬ebook, 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, ¬ebook, 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, ¬ebook, 10);
|
||
for a in click_at(&ctx, &mut state, ¬ebook, 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, ¬ebook, 3);
|
||
ctx.begin_frame(egui::RawInput::default());
|
||
let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||
let _ = ctx.end_frame();
|
||
let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages));
|
||
assert_eq!(requested, expected, "ungeladene Seite: {unloaded}");
|
||
}
|
||
}
|
||
}
|