Files
ink-notes/src/ui.rs
T
harald df72981a9a
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m0s
feat(input): Wayland-Tablet-Protokoll für aktive Stifte (Lazarite) und Touchscreen-Pinch-to-Zoom
2026-10-06 14:58:59 +02:00

2111 lines
94 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Immediate-Mode UI mit `egui` für ink-notes.
//!
//! Stellt eine moderne, schwebende Top-Toolbar im Pill-Design bereit:
//! - Milchglas-Look (Transluzenter Dark-Theme-Hintergrund mit weichem Schatten)
//! - Abgerundete Ecken (14 px Radius) und feine Kante
//! - Visuelle Farbindikatoren (3 px hohe Balken) direkt an den Werkzeug-Buttons
//! - Schwebendes Popover-Menü zur Konfiguration von Stiftfarbe, Dicken-Presets mit Linien-Vorschau und stufenlosem Slider
//! - Exaktes Hit-Testing für Event-Transparenz außerhalb der Pill und des Popovers
//! - Werkzeugauswahl: Stift (Pen), Textmarker (Highlighter), Radierer (Eraser) mit Akzent-Highlight
//! - Undo & Redo (mit dynamischer Deaktivierung)
//! - Diskrete Seitennavigation (◄, Seite X/Y mit Schnellauswahl, ►, ➕ Neue Seite)
//! - Hintergrundraster-Dropdown (Kariert, Liniert, Punktraster, Blanko)
//! - PDF-Export-Button mit Feedback-Banner
//! - Autosave-Statusanzeige (Live-Badge mit Farbindikator)
use std::time::Instant;
use crate::autosave::AutosaveStatus;
use crate::document::{Notebook, PageBackground};
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)
}
}
/// 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),
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),
RefreshNotebooks,
// Gitea Release & Updater
CheckForUpdates,
DownloadUpdateAsset(crate::updater::GiteaAsset),
InstallOrOpenUpdate(std::path::PathBuf),
OpenUrlInBrowser(String),
SaveUpdaterConfig {
server_url: String,
auth_token: Option<String>,
},
}
/// 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,
}
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,
}
}
}
impl UiState {
/// Radius der abgerundeten Ecken für die Toolbar-Pill.
pub const TOOLBAR_ROUNDING: f32 = 14.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
}
}
/// Stilvoller Werkzeug-Button mit 3 px hohem Farbindikator-Balken an der Unterkante
/// und Ausklapp-Indikator (▾ / ▴).
fn tool_pill_button_with_indicator(
ui: &mut egui::Ui,
is_active: bool,
text: &str,
indicator_color: [f32; 4],
is_menu_open: bool,
tooltip: &str,
) -> egui::Response {
let size = egui::vec2(90.0, 30.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let (bg, text_color, stroke) = if is_active {
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
egui::Color32::WHITE,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
egui::Color32::WHITE,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Color32::from_gray(215),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
),
)
};
// Button-Hintergrund
ui.painter()
.rect_filled(rect, egui::Rounding::same(8.0), bg);
ui.painter()
.rect_stroke(rect, egui::Rounding::same(8.0), stroke);
// Text & Caret
let label = if is_menu_open {
format!("{} ▴", text)
} else {
format!("{} ▾", text)
};
ui.painter().text(
egui::pos2(rect.center().x, rect.center().y - 2.5),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(12.5),
text_color,
);
// 3 px hoher Farbbalken an der Unterkante
let bar_margin_x = 10.0;
let bar_h = 3.0;
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.min.x + bar_margin_x, rect.max.y - bar_h - 2.5),
egui::pos2(rect.max.x - bar_margin_x, rect.max.y - 2.5),
);
let bar_color = egui::Color32::from_rgba_unmultiplied(
(indicator_color[0] * 255.0) as u8,
(indicator_color[1] * 255.0) as u8,
(indicator_color[2] * 255.0) as u8,
((indicator_color[3] * 255.0) as u8).max(180),
);
ui.painter()
.rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color);
}
response.on_hover_text(tooltip)
}
/// Stilvoller einfacher Werkzeug-Button (für den Radierer).
fn tool_pill_button(
ui: &mut egui::Ui,
is_active: bool,
text: &str,
tooltip: &str,
) -> egui::Response {
let (bg, text_color, stroke) = if is_active {
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
egui::Color32::WHITE,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Color32::from_gray(215),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
),
)
};
let button = egui::Button::new(
egui::RichText::new(text)
.color(text_color)
.size(13.0)
.strong(),
)
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(8.0))
.min_size(egui::vec2(0.0, 28.0));
ui.add(button).on_hover_text(tooltip)
}
/// 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, 34.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_premultiplied(120, 175, 255, 200),
),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(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))
}
/// Kompakter Strichbreiten-Preset-Button für die Haupt-Toolbar.
fn preset_width_button(ui: &mut egui::Ui, is_active: bool, text: &str) -> egui::Response {
let (bg, text_color, stroke) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 230),
egui::Color32::WHITE,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(120, 175, 255, 190),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 6),
egui::Color32::from_gray(190),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 12),
),
)
};
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(11.5))
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(6.0))
.min_size(egui::vec2(0.0, 24.0));
ui.add(button)
}
/// Abgerundeter Aktions-Button (Undo, Redo, Navigation, PDF).
fn action_pill_button(
ui: &mut egui::Ui,
enabled: bool,
text: &str,
tooltip: &str,
) -> egui::Response {
let text_color = if enabled {
egui::Color32::from_gray(230)
} else {
egui::Color32::from_gray(105)
};
let (bg, stroke) = if enabled {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 10),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 22),
),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 3),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
),
)
};
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(12.5))
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(7.0))
.min_size(egui::vec2(0.0, 26.0));
ui.add_enabled(enabled, button).on_hover_text(tooltip)
}
/// Feine, transparente Trennlinie für Gruppenabstände.
fn toolbar_divider(ui: &mut egui::Ui) {
ui.add_space(2.0);
let (rect, _) = ui.allocate_exact_size(egui::vec2(1.0, 20.0), egui::Sense::hover());
ui.painter().vline(
rect.center().x,
rect.y_range(),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
),
);
ui.add_space(2.0);
}
/// 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);
let stroke = if is_selected {
egui::Stroke::new(2.5_f32, egui::Color32::WHITE)
} else if response.hovered() {
egui::Stroke::new(1.8_f32, egui::Color32::from_white_alpha(200))
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(65))
};
let radius = (diameter / 2.0) - 1.5;
ui.painter().circle_filled(rect.center(), radius, c32);
ui.painter().circle_stroke(rect.center(), radius, stroke);
}
response.on_hover_text(tooltip)
}
/// 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;
// 1. Schwebende Top-Toolbar (zentriert oben mit 16 px Abstand zum Fensterrand)
let area_response = egui::Area::new(egui::Id::new("floating_toolbar_area"))
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 16.0))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame_response = egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 225)) // Transluzenter Milchglas-Hintergrund
.rounding(egui::Rounding::same(UiState::TOOLBAR_ROUNDING)) // Abgerundete Pill-Ecken (14 px)
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), // Feine Kante
))
.inner_margin(egui::Margin::symmetric(14.0, 8.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), // Weicher Schattenwurf
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
// --- 0. DOKUMENTEN- & NOTIZBUCH-AUSWAHL ---
let doc_title_truncated = if state.document_title.chars().count() > 16 {
let prefix: String = state.document_title.chars().take(14).collect();
format!("{prefix}…")
} else if state.document_title.is_empty() {
"Notizbuch".to_string()
} else {
state.document_title.clone()
};
let doc_btn_label = format!("📚 {} ▾", doc_title_truncated);
let doc_resp = tool_pill_button(
ui,
state.is_document_menu_open,
&doc_btn_label,
"Dokumenten-Manager: Notizbuch wechseln, anlegen oder verwalten",
);
if doc_resp.clicked() {
state.is_document_menu_open = !state.is_document_menu_open;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
if state.is_document_menu_open {
actions.push(UiAction::RefreshNotebooks);
}
}
toolbar_divider(ui);
// --- 1. WERKZEUGAUSWAHL MIT FARBINDIKATOR ---
let pen_resp = tool_pill_button_with_indicator(
ui,
state.active_tool == ActiveTool::Pen,
"✏ Stift",
state.pen_color,
state.is_pen_menu_open,
"Stift wählen / Einstellungen öffnen (Farbe & Stärke)",
);
pen_btn_rect = Some(pen_resp.rect);
if pen_resp.clicked() {
if state.active_tool != ActiveTool::Pen {
state.active_tool = ActiveTool::Pen;
state.is_pen_menu_open = true;
state.is_highlighter_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 {
state.is_pen_menu_open = !state.is_pen_menu_open;
state.is_highlighter_menu_open = false;
}
}
let high_resp = tool_pill_button_with_indicator(
ui,
state.active_tool == ActiveTool::Highlighter,
"🖍 Marker",
state.highlighter_color,
state.is_highlighter_menu_open,
"Textmarker wählen / Einstellungen öffnen",
);
high_btn_rect = Some(high_resp.rect);
if high_resp.clicked() {
if state.active_tool != ActiveTool::Highlighter {
state.active_tool = ActiveTool::Highlighter;
state.is_highlighter_menu_open = true;
state.is_pen_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 {
state.is_highlighter_menu_open = !state.is_highlighter_menu_open;
state.is_pen_menu_open = false;
}
}
let shape_resp = tool_pill_button(
ui,
state.active_tool == ActiveTool::Shape,
"📐 Formen",
"Geometrie / Auto-Snap (Tippe auf Striche zur perfekten Formerkennung)",
);
if shape_resp.clicked() {
state.active_tool = ActiveTool::Shape;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
actions.push(UiAction::SetTool(ActiveTool::Shape));
}
if tool_pill_button(
ui,
state.active_tool == ActiveTool::Eraser,
"⌫ Radierer",
"Strichradierer (Taste: E oder Rechtsklick)",
)
.clicked()
{
state.active_tool = ActiveTool::Eraser;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
actions.push(UiAction::SetTool(ActiveTool::Eraser));
}
let select_resp = tool_pill_button(
ui,
state.active_tool == ActiveTool::Select,
"⬚ Auswählen",
"Objektauswahl / Lasso (Taste: V) - Verschieben, Drehen, Skalieren, Kopieren, Löschen",
);
if select_resp.clicked() {
state.active_tool = ActiveTool::Select;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
actions.push(UiAction::SetTool(ActiveTool::Select));
}
toolbar_divider(ui);
// --- 2. FARBPALETTE SCHNELLAUSWAHL ---
if state.active_tool == ActiveTool::Pen || state.active_tool == ActiveTool::Shape || state.active_tool == ActiveTool::Select {
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, 20.0).clicked() {
state.pen_color = color;
actions.push(UiAction::SetColor(color));
}
}
} else if state.active_tool == ActiveTool::Highlighter {
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, 20.0).clicked() {
state.highlighter_color = color;
actions.push(UiAction::SetColor(color));
}
}
}
// Bei aktiver Auswahl: Direkte Schnell-Aktionen in der Toolbar
if state.active_tool == ActiveTool::Select {
toolbar_divider(ui);
if ui.button(egui::RichText::new("📋 Kopieren").size(12.0)).on_hover_text("Auswahl in die Zwischenablage kopieren (Strg+C)").clicked() {
actions.push(UiAction::CopySelected);
}
if ui.button(egui::RichText::new("📥 Einfügen").size(12.0)).on_hover_text("Aus Zwischenablage einfügen (Strg+V)").clicked() {
actions.push(UiAction::PasteSelected);
}
if ui.button(egui::RichText::new("🔄 90° Drehen").size(12.0)).on_hover_text("Ausgewählte Objekte um 90° im Uhrzeigersinn drehen (R)").clicked() {
actions.push(UiAction::RotateSelected);
}
if ui.button(egui::RichText::new("🗑 Löschen").color(egui::Color32::from_rgb(255, 110, 110)).size(12.0)).on_hover_text("Ausgewählte Objekte entfernen (Entf/Backspace)").clicked() {
actions.push(UiAction::DeleteSelected);
}
}
if state.active_tool != ActiveTool::Eraser && state.active_tool != ActiveTool::Select {
toolbar_divider(ui);
}
// --- 3. STRICHBREITEN-SCHNELLAUSWAHL ---
if state.active_tool == ActiveTool::Pen || state.active_tool == ActiveTool::Shape {
for &(preset, label) in &PEN_WIDTH_PRESETS {
let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05;
if preset_width_button(ui, is_sel, label).clicked() {
state.pen_stroke_width_mm = preset;
actions.push(UiAction::SetStrokeWidthMm(preset));
}
}
toolbar_divider(ui);
} else if state.active_tool == ActiveTool::Highlighter {
for &(preset, label) in &HIGHLIGHTER_WIDTH_PRESETS {
let is_sel = (state.highlighter_stroke_width_mm - preset).abs() < 0.1;
if preset_width_button(ui, is_sel, label).clicked() {
state.highlighter_stroke_width_mm = preset;
actions.push(UiAction::SetStrokeWidthMm(preset));
}
}
toolbar_divider(ui);
}
// --- 4. UNDO & REDO ---
let can_undo = notebook.current_page().history.can_undo();
let can_redo = notebook.current_page().history.can_redo();
if action_pill_button(ui, can_undo, "⟲ Undo", "Rückgängig (Strg + Z)").clicked() {
actions.push(UiAction::Undo);
}
if action_pill_button(ui, can_redo, "⟳ Redo", "Wiederholen (Strg + Y)").clicked() {
actions.push(UiAction::Redo);
}
toolbar_divider(ui);
// --- 5. DISKRETE SEITENNAVIGATION ---
let can_prev = notebook.current_page_index > 0;
if action_pill_button(ui, can_prev, "◄", "Vorherige Seite (PageUp)").clicked() {
actions.push(UiAction::PreviousPage);
}
let page_badge_text = format!(
"Seite {} / {}",
notebook.current_page_index + 1,
notebook.pages.len()
);
if action_pill_button(ui, true, &page_badge_text, "Schnellauswahl öffnen").clicked() {
state.show_page_jump_popup = !state.show_page_jump_popup;
}
if action_pill_button(ui, true, "►", "Nächste Seite (PageDown / N)").clicked() {
actions.push(UiAction::NextPage);
}
if action_pill_button(ui, true, "➕", "Neue Seite anlegen").clicked() {
actions.push(UiAction::AddPage);
}
toolbar_divider(ui);
// --- 6. HINTERGRUNDRASTER-DROPDOWN ---
let current_bg = notebook.current_page().background;
let current_bg_text = match current_bg {
PageBackground::Grid5mm => "Kariert (5mm)",
PageBackground::Lined => "Liniert (7.5mm)",
PageBackground::DotGrid => "Punktraster (5mm)",
PageBackground::Blank => "Blanko",
};
egui::ComboBox::from_id_source("bg_dropdown_picker")
.selected_text(current_bg_text)
.show_ui(ui, |ui| {
if ui
.selectable_label(current_bg == PageBackground::Grid5mm, "Kariert (5mm)")
.clicked()
{
actions.push(UiAction::SetBackground(PageBackground::Grid5mm));
}
if ui
.selectable_label(current_bg == PageBackground::Lined, "Liniert (7.5mm)")
.clicked()
{
actions.push(UiAction::SetBackground(PageBackground::Lined));
}
if ui
.selectable_label(current_bg == PageBackground::DotGrid, "Punktraster (5mm)")
.clicked()
{
actions.push(UiAction::SetBackground(PageBackground::DotGrid));
}
if ui
.selectable_label(current_bg == PageBackground::Blank, "Blanko")
.clicked()
{
actions.push(UiAction::SetBackground(PageBackground::Blank));
}
});
toolbar_divider(ui);
// --- 7. PDF-EXPORT-BUTTON ---
if action_pill_button(
ui,
true,
"📄 PDF",
"Dokument als Vektor-PDF exportieren (Strg + E / S)",
)
.clicked()
{
actions.push(UiAction::ExportPdf);
}
toolbar_divider(ui);
// --- 8. ZOOM-STEUERUNG & ANZEIGE ---
if action_pill_button(ui, state.current_zoom > 0.25, "🔍-", "Herauszoomen (Mausrad)").clicked() {
actions.push(UiAction::ZoomOut);
}
let zoom_pct = (state.current_zoom * 100.0).round() as u32;
let zoom_badge = format!("{}%", zoom_pct);
if action_pill_button(ui, true, &zoom_badge, "Zoom auf 100% zentriert zurücksetzen (Strg + 0)").clicked() {
actions.push(UiAction::ResetZoom);
}
if action_pill_button(ui, state.current_zoom < 5.0, "🔍+", "Heranzoomen (Mausrad)").clicked() {
actions.push(UiAction::ZoomIn);
}
toolbar_divider(ui);
// --- 9. TOUCH-MODUS UMSCHALTER (Gesten vs. Zeichnen) ---
let (touch_icon, touch_tt) = if state.touch_draw_enabled {
("✏️ Touch: Stift", "Touch-Modus: Einzelfinger zeichnet, zwei Finger zoomen/verschieben. Klicken zum Umschalten auf reine Gesten-Navigation.")
} else {
("🖐️ Touch: Zoom/Pan", "Touch-Modus: Finger zoomen und verschieben die Seite (ideal für Stifte wie Lazarite!). Klicken für Finger-Zeichnen.")
};
if action_pill_button(ui, true, touch_icon, touch_tt).clicked() {
state.touch_draw_enabled = !state.touch_draw_enabled;
}
toolbar_divider(ui);
// --- 10. AUTOSAVE-STATUSANZEIGE ---
let (dot_color, label_text) = match autosave_status {
AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"),
AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert..."),
AutosaveStatus::Pending => (egui::Color32::from_rgb(230, 126, 34), "Ungespeichert"),
AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), "Fehler"),
};
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0);
ui.colored_label(dot_color, "●");
ui.label(
egui::RichText::new(label_text)
.color(dot_color)
.size(11.5)
.strong(),
);
});
toolbar_divider(ui);
// --- 10. GITEA-RELEASE & UPDATE-STATUS ---
let (upd_label, is_highlight) = match updater.status() {
crate::updater::UpdaterStatus::UpdateAvailable(rel) => {
(format!("🚀 Neu: {}", rel.tag_name), true)
}
crate::updater::UpdaterStatus::Downloading { percent, .. } => {
(format!("⏳ {:.0}%", percent * 100.0), true)
}
crate::updater::UpdaterStatus::DownloadFinished { .. } => {
("✓ Update bereit".to_string(), true)
}
crate::updater::UpdaterStatus::Checking => {
("🔄 Prüfe...".to_string(), false)
}
_ => ("🔄 Update".to_string(), false),
};
let upd_btn = if is_highlight {
let text = egui::RichText::new(&upd_label)
.color(egui::Color32::from_rgb(46, 204, 113))
.strong();
ui.add(egui::Button::new(text).frame(true))
.on_hover_text("Update verfügbar! Klicke hier für Release-Notes und Download.")
} else {
action_pill_button(
ui,
true,
&upd_label,
"Release- & Update-Manager (Gitea Releases prüfen & installieren)",
)
};
if upd_btn.clicked() {
state.is_update_dialog_open = !state.is_update_dialog_open;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_document_menu_open = false;
}
});
});
frame_response.response.rect
});
// Toolbar-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 = egui::pos2(btn_rect.min.x - 12.0, btn_rect.max.y + 8.0);
let pop_response = egui::Area::new(egui::Id::new("pen_settings_popover"))
.fixed_pos(popover_pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235)) // Milchglas-Look
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30),
))
.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),
})
.show(ui, |ui| {
ui.set_width(295.0);
// 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 ui
.add(
egui::Button::new(
egui::RichText::new("✕").size(11.0),
)
.frame(false),
)
.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, 22.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 = egui::pos2(btn_rect.min.x - 12.0, btn_rect.max.y + 8.0);
let pop_response = egui::Area::new(egui::Id::new("highlighter_settings_popover"))
.fixed_pos(popover_pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30),
))
.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),
})
.show(ui, |ui| {
ui.set_width(280.0);
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 ui
.add(
egui::Button::new(
egui::RichText::new("✕").size(11.0),
)
.frame(false),
)
.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, 22.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);
}
}
state.popover_rect = popover_rect;
// Automatisches Schließen des Popovers bei Klick außerhalb von Toolbar und Popover
if (state.is_pen_menu_open || state.is_highlighter_menu_open)
&& 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;
}
}
}
// 3. Schnellauswahl-Fenster für Seiten (im gleichen Milchglas-Look)
if state.show_page_jump_popup {
egui::Window::new("Seiten-Schnellauswahl")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30),
))
.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),
}),
)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!("Notizbuch: {} Seite(n)", notebook.pages.len()))
.strong()
.color(egui::Color32::WHITE),
);
ui.separator();
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for idx in 0..notebook.pages.len() {
let is_current = idx == notebook.current_page_index;
let text = format!("Seite {}", idx + 1);
if tool_pill_button(ui, is_current, &text, "").clicked() {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
}
});
ui.separator();
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
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 = egui::Window::new("Dokumenten-Manager")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 242))
.rounding(egui::Rounding::same(14.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(18.0, 16.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 20.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(140),
}),
)
.show(ctx, |ui| {
ui.set_width(380.0);
// Kopfzeile
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("📚 Notizbücher verwalten")
.strong()
.size(15.0)
.color(egui::Color32::WHITE),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("✕").size(12.0)).frame(false))
.clicked()
{
state.is_document_menu_open = false;
}
});
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// 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 Notizbuchs...")
.desired_width(260.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)
.desired_width(230.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_premultiplied(110, 165, 255, 180)),
)
} else {
(
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(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 is_current {
ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv");
}
});
let subtext = 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 state.available_notebooks.len() > 1
&& ui
.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
.on_hover_text("Notizbuch unwiderruflich löschen")
.clicked()
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
if ui.button(egui::RichText::new("✏").size(11.0))
.on_hover_text("Titel umbenennen")
.clicked()
{
state.renaming_notebook_id = Some(summary.id.clone());
state.renaming_title_input = summary.title.clone();
}
});
});
});
if 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);
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if action_pill_button(ui, true, "Schließen", "Dokumenten-Manager schließen").clicked() {
state.is_document_menu_open = false;
}
});
});
state.document_modal_rect = doc_win.map(|w| w.response.rect);
} else {
state.document_modal_rect = None;
}
// 3c. Release- & Update-Center (Gitea Releases)
if state.is_update_dialog_open {
let win = egui::Window::new("Release- & Update-Center")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 245))
.rounding(egui::Rounding::same(14.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_premultiplied(255, 255, 255, 30),
))
.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(150),
}),
)
.show(ctx, |ui| {
ui.set_width(450.0);
// Kopfzeile mit Schließen-Button
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("🚀 Gitea Release- & Update-Center")
.strong()
.size(16.0)
.color(egui::Color32::WHITE),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("✕").size(13.0)).frame(false))
.clicked()
{
state.is_update_dialog_open = false;
}
});
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// 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,
});
}
});
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
state.is_update_dialog_open = false;
}
});
});
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;
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ui_state_defaults() {
let state = UiState::default();
assert_eq!(state.active_tool, ActiveTool::Pen);
assert_eq!(state.pen_stroke_width_mm, 0.5);
assert_eq!(state.highlighter_stroke_width_mm, 6.0);
assert_eq!(state.current_stroke_width_mm(), 0.5);
assert!(!state.show_page_jump_popup);
assert!(!state.is_pen_menu_open);
assert!(!state.is_highlighter_menu_open);
assert!(!state.is_document_menu_open);
assert_eq!(state.current_notebook_id, "default");
assert_eq!(state.document_title, "Standard-Notizbuch");
assert!(state.export_message.is_none());
assert!(state.toolbar_rect.is_none());
assert!(state.popover_rect.is_none());
}
#[test]
fn test_active_tool_properties() {
assert!(ActiveTool::Pen.is_drawing());
assert!(ActiveTool::Highlighter.is_drawing());
assert!(!ActiveTool::Eraser.is_drawing());
assert!(!ActiveTool::Select.is_drawing());
assert_eq!(ActiveTool::Pen.name(), "Stift (Pen)");
assert_eq!(ActiveTool::Eraser.name(), "Radierer (Eraser)");
assert_eq!(ActiveTool::Select.name(), "Auswahl / Lasso (Select)");
}
#[test]
fn test_render_ui_generates_shapes_and_rect() {
let ctx = egui::Context::default();
let mut state = UiState::new();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
ctx.begin_frame(egui::RawInput::default());
let actions = render_ui(&ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
let output = ctx.end_frame();
assert!(actions.is_empty());
assert!(!output.shapes.is_empty());
assert!(state.toolbar_rect.is_some());
}
#[test]
fn test_pill_hit_testing() {
let mut state = UiState::new();
state.toolbar_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 16.0),
egui::pos2(800.0, 60.0),
));
// 1. Punkt im Zentrum der Pill -> getroffen
assert!(state.is_point_inside_toolbar(500.0, 38.0));
// 2. Punkt oberhalb der Pill (in der 16 px Lücke zum Fensterrand) -> frei
assert!(!state.is_point_inside_toolbar(500.0, 8.0));
// 3. Punkt weit außerhalb links/rechts -> frei
assert!(!state.is_point_inside_toolbar(50.0, 38.0));
assert!(!state.is_point_inside_toolbar(950.0, 38.0));
// 4. Punkt unterhalb der Pill -> frei
assert!(!state.is_point_inside_toolbar(500.0, 100.0));
// 5. Eckpunkt ganz außen in der Bounding Box (außerhalb des 14 px Kreises) -> frei!
assert!(!state.is_point_inside_toolbar(201.0, 17.0));
}
#[test]
fn test_popover_hit_testing() {
let mut state = UiState::new();
state.toolbar_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 16.0),
egui::pos2(800.0, 60.0),
));
state.popover_rect = Some(egui::Rect::from_min_max(
egui::pos2(200.0, 68.0),
egui::pos2(480.0, 240.0),
));
// Klick in Toolbar -> UI
assert!(state.is_point_in_ui(500.0, 38.0));
// Klick in Popover -> UI
assert!(state.is_point_in_ui(300.0, 120.0));
// Klick außerhalb -> frei für Canvas
assert!(!state.is_point_in_ui(600.0, 150.0));
}
#[test]
fn test_pen_color_presets() {
assert_eq!(PEN_COLOR_PRESETS.len(), 5);
assert_eq!(PEN_COLOR_PRESETS[0].1, "Notiz-Schwarz");
assert_eq!(PEN_COLOR_PRESETS[1].1, "Universitäts-Blau");
assert_eq!(PEN_COLOR_PRESETS[2].1, "Korrektur-Rot");
assert_eq!(PEN_COLOR_PRESETS[3].1, "Tafel-Grün");
assert_eq!(PEN_COLOR_PRESETS[4].1, "Graphit-Grau");
}
#[test]
fn test_pen_width_presets() {
assert_eq!(PEN_WIDTH_PRESETS.len(), 4);
assert_eq!(PEN_WIDTH_PRESETS[0].0, 0.3);
assert_eq!(PEN_WIDTH_PRESETS[1].0, 0.5);
assert_eq!(PEN_WIDTH_PRESETS[2].0, 0.8);
assert_eq!(PEN_WIDTH_PRESETS[3].0, 1.2);
}
#[test]
fn test_document_manager_ui_actions() {
let mut state = UiState::new();
state.is_document_menu_open = true;
state.available_notebooks = vec![
NotebookSummary {
id: "nb-1".to_string(),
title: "Mathematik I".to_string(),
page_count: 3,
modified_at: 100,
},
NotebookSummary {
id: "nb-2".to_string(),
title: "Physik II".to_string(),
page_count: 5,
modified_at: 200,
},
];
state.current_notebook_id = "nb-1".to_string();
state.document_title = "Mathematik I".to_string();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let ctx = egui::Context::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let _ = super::render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
});
assert!(state.is_document_menu_open);
assert_eq!(state.available_notebooks.len(), 2);
}
#[test]
fn test_updater_modal_ui_actions() {
let mut state = UiState::new();
state.is_update_dialog_open = true;
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let ctx = egui::Context::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
let _ = super::render_ui(ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
});
assert!(state.is_update_dialog_open);
assert!(state.update_modal_rect.is_some());
if let Some(rect) = state.update_modal_rect {
assert!(state.is_point_in_ui(rect.center().x, rect.center().y));
}
}
#[test]
fn test_touch_draw_mode_toggle() {
let mut state = UiState::new();
assert!(!state.touch_draw_enabled); // Default: false = Gesten-Navigation & Zoom
state.touch_draw_enabled = true;
assert!(state.touch_draw_enabled);
}
}