diff --git a/README.md b/README.md index 03a2423..3243f82 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PD Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). +Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“. + --- ## Funktionen @@ -29,7 +31,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ### Formerkennung - **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt. -- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper). +- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper). - Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift. ### Auswählen und Bearbeiten diff --git a/src/assets.rs b/src/assets.rs index fe4ea9c..01c41ec 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use crate::storage; +use crate::t; /// Name des Unterordners mit den Bilddateien. pub const ASSETS_DIR: &str = "assets"; @@ -40,15 +41,18 @@ pub enum AssetError { impl std::fmt::Display for AssetError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"), - AssetError::TooLarge(bytes) => write!( - f, - "Bild ist zu groß ({} MB, höchstens {} MB)", - bytes / (1024 * 1024), - MAX_IMPORT_BYTES / (1024 * 1024) - ), - AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"), - AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"), + AssetError::Decode(msg) => { + f.write_str(&t!("Bild kann nicht gelesen werden: {msg}", msg = msg)) + } + AssetError::TooLarge(bytes) => f.write_str(&t!( + "Bild ist zu groß ({size} MB, höchstens {max} MB)", + size = bytes / (1024 * 1024), + max = MAX_IMPORT_BYTES / (1024 * 1024) + )), + AssetError::InvalidName(name) => { + f.write_str(&t!("Ungültiger Bildverweis '{name}'", name = name)) + } + AssetError::Io(e) => f.write_str(&t!("Ein-/Ausgabefehler: {e}", e = e)), } } } @@ -164,8 +168,9 @@ impl AssetStore { height: u32, pixels: Vec, ) -> Result { - let buffer = image::RgbaImage::from_raw(width, height, pixels) - .ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?; + let buffer = image::RgbaImage::from_raw(width, height, pixels).ok_or_else(|| { + AssetError::Decode(t!("Pixeldaten passen nicht zur Bildgröße").into()) + })?; let mut decoded = image::DynamicImage::ImageRgba8(buffer); if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION { decoded = decoded.resize( diff --git a/src/autosave.rs b/src/autosave.rs index 279bef6..01fa0f0 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::document::{Notebook, Page, PersistState}; use crate::storage::{self, NotebookManifest, PageData, StorageError}; +use crate::t; /// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep). pub trait Clock: Send + Sync + 'static { @@ -69,12 +70,11 @@ pub struct AutosaveError { impl fmt::Display for AutosaveError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Autosave fehlgeschlagen für Seiten {:?}: {}", - self.failed_page_ids, - self.messages.join("; ") - ) + f.write_str(&t!( + "Autosave fehlgeschlagen für Seiten {pages}: {messages}", + pages = format!("{:?}", self.failed_page_ids), + messages = self.messages.join("; ") + )) } } @@ -96,10 +96,10 @@ pub enum AutosaveStatus { impl AutosaveStatus { pub fn display_text(&self) -> &'static str { match self { - AutosaveStatus::Saved => "Gespeichert", - AutosaveStatus::Pending => "Ungespeichert", - AutosaveStatus::Saving => "Speichert...", - AutosaveStatus::Error => "Speicherfehler!", + AutosaveStatus::Saved => t!("Gespeichert"), + AutosaveStatus::Pending => t!("Ungespeichert"), + AutosaveStatus::Saving => t!("Speichert..."), + AutosaveStatus::Error => t!("Speicherfehler!"), } } } @@ -166,7 +166,7 @@ fn spawn_worker( let _ = resp_tx.send(AutosaveResponse::PageSaved { page_id, result: Err(( - "Worker panic beim Speichern der Seite".to_string(), + t!("Worker panic beim Speichern der Seite").to_string(), Box::new(page_data), )), }); @@ -194,7 +194,7 @@ fn spawn_worker( Err(_panic) => { let _ = resp_tx.send(AutosaveResponse::ManifestSaved { result: Err(( - "Worker panic beim Speichern des Manifests".to_string(), + t!("Worker panic beim Speichern des Manifests").to_string(), Box::new(manifest), )), }); @@ -413,7 +413,8 @@ impl AutosaveController { Err((err_msg, failed_page)) => { eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}"); self.status = AutosaveStatus::Error; - self.last_errors.push(format!("Seite {page_id}: {err_msg}")); + self.last_errors + .push(t!("Seite {id}: {msg}", id = page_id, msg = err_msg)); // Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt self.pending_pages.entry(page_id).or_insert(*failed_page); @@ -431,7 +432,7 @@ impl AutosaveController { if let Err((err_msg, failed_manifest)) = result { eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}"); self.status = AutosaveStatus::Error; - self.last_errors.push(format!("Manifest: {err_msg}")); + self.last_errors.push(t!("Manifest: {msg}", msg = err_msg)); if self.pending_manifest.is_none() { self.pending_manifest = Some(*failed_manifest); } @@ -567,7 +568,8 @@ impl AutosaveController { } Err((e, failed_page)) => { self.status = AutosaveStatus::Error; - self.last_errors.push(format!("Seite {page_id}: {e}")); + self.last_errors + .push(t!("Seite {id}: {msg}", id = page_id, msg = e)); self.pending_pages.insert(page_id, *failed_page); let now = self.clock.now(); let retry = self.retries.entry(page_id).or_insert(PageRetry { @@ -581,7 +583,7 @@ impl AutosaveController { AutosaveResponse::ManifestSaved { result } => { if let Err((e, failed_manifest)) = result { self.status = AutosaveStatus::Error; - self.last_errors.push(format!("Manifest: {e}")); + self.last_errors.push(t!("Manifest: {msg}", msg = e)); self.pending_manifest = Some(*failed_manifest); } } diff --git a/src/background.rs b/src/background.rs index 5362f08..7e4ed55 100644 --- a/src/background.rs +++ b/src/background.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::canvas::InkVertex; use crate::document::PageBackground; +use crate::t; /// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export). /// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers. @@ -29,11 +30,11 @@ impl PatternColor { pub fn label(self) -> &'static str { match self { - PatternColor::Light => "Hell", - PatternColor::Medium => "Mittel", - PatternColor::Strong => "Kräftig", - PatternColor::Blue => "Blau", - PatternColor::Green => "Grün", + PatternColor::Light => t!("Hell"), + PatternColor::Medium => t!("Mittel"), + PatternColor::Strong => t!("Kräftig"), + PatternColor::Blue => t!("Blau"), + PatternColor::Green => t!("Grün"), } } @@ -96,10 +97,10 @@ impl PaperTheme { pub fn label(self) -> &'static str { match self { - PaperTheme::White => "Weiß", - PaperTheme::Cream => "Creme", - PaperTheme::DarkGray => "Dunkelgrau", - PaperTheme::Black => "Schwarz", + PaperTheme::White => t!("Weiß"), + PaperTheme::Cream => t!("Creme"), + PaperTheme::DarkGray => t!("Dunkelgrau"), + PaperTheme::Black => t!("Schwarz"), } } diff --git a/src/canvas.rs b/src/canvas.rs index b46387e..da09a9b 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -18,6 +18,7 @@ use crate::background::{ use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; +use crate::tn; use crate::viewport::PageStack; /// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color). @@ -93,12 +94,13 @@ pub fn spline_vertices_to_ndc( } /// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. -/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann. -pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\ +/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann (deutsch, anzeigen mit +/// `i18n::tr`). +pub const NO_USABLE_GPU_MESSAGE: &str = tn!("Keine nutzbare Grafikschnittstelle gefunden.\n\ Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\ Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\ und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\ - oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`."; + oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`."); /// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten /// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo. @@ -305,7 +307,8 @@ impl CanvasState { ), } } - let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?; + let (adapter, surface_caps, present_mode) = + chosen.ok_or(crate::i18n::tr(NO_USABLE_GPU_MESSAGE))?; // 3. Device & Queue erstellen let (device, queue) = adapter @@ -733,7 +736,7 @@ impl CanvasState { if !self.presentation_warned { eprintln!( "[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}", - NO_USABLE_GPU_MESSAGE + crate::i18n::tr(NO_USABLE_GPU_MESSAGE) ); self.presentation_warned = true; } diff --git a/src/document.rs b/src/document.rs index 87435e2..01c01e5 100644 --- a/src/document.rs +++ b/src/document.rs @@ -5,6 +5,7 @@ use std::ops::Range; use crate::canvas::InkVertex; use crate::history::{HistoryAction, HistoryStack}; pub use crate::spline::{finalize_stroke, finalize_stroke_with_config}; +use crate::t; /// Standardmaße DIN A4 in Millimetern. pub const A4_WIDTH_MM: f32 = 210.0; @@ -1306,10 +1307,10 @@ impl Notebook { /// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12". pub fn osd_text(&self) -> String { - format!( - "Seite {} / {}", - self.current_page_index + 1, - self.pages.len() + t!( + "Seite {number} / {count}", + number = self.current_page_index + 1, + count = self.pages.len() ) } } diff --git a/src/export.rs b/src/export.rs index 9ed63c4..610bda4 100644 --- a/src/export.rs +++ b/src/export.rs @@ -24,6 +24,7 @@ use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING} use crate::document::{ Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, }; +use crate::t; use ab_glyph::Font as _; use crate::text::TextFont; @@ -384,15 +385,15 @@ fn generate_pdf_document_with( raster_pdf_pages: &std::collections::HashSet, ) -> Result> { if notebook.pages.is_empty() { - return Err("Notizbuch enthält keine Seiten".into()); + return Err(t!("Notizbuch enthält keine Seiten").into()); } // Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert if let Some(page) = notebook.pages.iter().find(|p| { !p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError }) { - return Err(format!( - "Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", - page.id + return Err(t!( + "Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", + id = page.id ) .into()); } @@ -823,7 +824,7 @@ fn render_pdf_raster( let longest_pt = pdf.width_pt.max(pdf.height_pt); let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32; let rendered = assets - .ok_or_else(|| "kein Asset-Ordner".to_string()) + .ok_or_else(|| t!("kein Asset-Ordner").to_string()) .and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string())) .and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels)); let xobject = match rendered { diff --git a/src/file_picker.rs b/src/file_picker.rs index a751796..ed92b54 100644 --- a/src/file_picker.rs +++ b/src/file_picker.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use crate::assets::is_supported_image_path; +use crate::{t, tn}; /// Ein Eintrag der Dateiliste. #[derive(Debug, Clone, PartialEq, Eq)] @@ -61,8 +62,8 @@ impl FilePicker { .map(Path::to_path_buf) .or_else(|| { let preferred = match kind { - PickerKind::Image => "🖼 Bilder", - PickerKind::Pdf => "⬇ Downloads", + PickerKind::Image => tn!("🖼 Bilder"), + PickerKind::Pdf => tn!("⬇ Downloads"), }; let all = shortcuts(); all.iter() @@ -90,9 +91,10 @@ impl FilePicker { self.error = None; } Err(err) => { - self.error = Some(format!( - "'{}' kann nicht geöffnet werden: {err}", - dir.display() + self.error = Some(t!( + "'{dir}' kann nicht geöffnet werden: {err}", + dir = dir.display(), + err = err )); } } @@ -131,7 +133,8 @@ pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result Vec<(&'static str, PathBuf)> { let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return Vec::new(); @@ -146,15 +149,15 @@ pub fn shortcuts() -> Vec<(&'static str, PathBuf)> { }; let mut list = Vec::new(); if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) { - list.push(("🖼 Bilder", p)); + list.push((tn!("🖼 Bilder"), p)); } if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) { - list.push(("⬇ Downloads", p)); + list.push((tn!("⬇ Downloads"), p)); } if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) { - list.push(("🖥 Schreibtisch", p)); + list.push((tn!("🖥 Schreibtisch"), p)); } - list.push(("🏠 Persönlicher Ordner", home)); + list.push((tn!("🏠 Persönlicher Ordner"), home)); list } diff --git a/src/geometry.rs b/src/geometry.rs index aff9f35..297f21e 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -9,6 +9,7 @@ use std::f32::consts::PI; use crate::canvas::InkVertex; use crate::document::{Aabb, Stroke, StylusSample}; +use crate::t; /// Arten der geometrischen Primitiven. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -104,26 +105,26 @@ impl RecognizedShape { pub fn name(&self) -> &'static str { match self { - Self::Line { .. } => "Gerade", - Self::Circle { .. } => "Kreis", - Self::Ellipse { .. } => "Ellipse", - Self::Arrow { double: false, .. } => "Pfeil", - Self::Arrow { double: true, .. } => "Doppelpfeil", - Self::Rectangle { .. } => "Rechteck", + Self::Line { .. } => t!("Gerade"), + Self::Circle { .. } => t!("Kreis"), + Self::Ellipse { .. } => t!("Ellipse"), + Self::Arrow { double: false, .. } => t!("Pfeil"), + Self::Arrow { double: true, .. } => t!("Doppelpfeil"), + Self::Rectangle { .. } => t!("Rechteck"), Self::CoordinateSystem { negative_ends: Some(_), .. - } => "Achsenkreuz", - Self::CoordinateSystem { .. } => "Koordinatensystem", - Self::Triangle { .. } => "Dreieck", - Self::Rhombus { .. } => "Raute (Rhombus)", + } => t!("Achsenkreuz"), + Self::CoordinateSystem { .. } => t!("Koordinatensystem"), + Self::Triangle { .. } => t!("Dreieck"), + Self::Rhombus { .. } => t!("Raute (Rhombus)"), Self::Polygon { vertices } => match vertices.len() { - 5 => "Fünfeck (Pentagon)", - 6 => "Sechseck (Hexagon)", - 8 => "Achteck (Oktagon)", - _ => "Polygon", + 5 => t!("Fünfeck (Pentagon)"), + 6 => t!("Sechseck (Hexagon)"), + 8 => t!("Achteck (Oktagon)"), + _ => t!("Polygon"), }, - Self::Octahedron { .. } => "Oktaeder (3D-Kantenmodell)", + Self::Octahedron { .. } => t!("Oktaeder (3D-Kantenmodell)"), } } diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..102c87d --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,326 @@ +//! Sprache der Oberfläche: Deutsch oder Englisch. +//! +//! Die Texte stehen im Quelltext auf Deutsch in `t!("…")`; dieser deutsche Text ist zugleich +//! der Schlüssel für die englische Fassung in `en.rs`. Platzhalter heißen `{name}` und werden +//! mit `t!("Seite {number}", number = n)` gefüllt (Formatangaben wie `{:.1}` vorher mit +//! `format!` anwenden). Texte in Tabellen markiert `tn!("…")`, übersetzt werden sie beim +//! Anzeigen mit `tr(…)`. Ein Test prüft, dass jeder markierte Text übersetzt ist. + +use std::collections::HashMap; +use std::fmt::Display; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +mod en; + +/// Sprache der Oberfläche +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Language { + German, + English, +} + +impl Language { + pub const ALL: [Language; 2] = [Language::German, Language::English]; + + /// Name der Sprache in ihr selbst (für die Auswahl, unabhängig von der aktuellen Sprache) + pub fn native_name(self) -> &'static str { + match self { + Language::German => "Deutsch", + Language::English => "English", + } + } + + /// Sprache aus den Locale-Variablen (`LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`; `C` und + /// `POSIX` zählen nicht): Deutsch bei `de…`, sonst Englisch. + pub fn from_locale(vars: &[Option]) -> Language { + let locale = vars + .iter() + .flatten() + .map(|v| v.trim()) + .find(|v| !v.is_empty() && *v != "C" && !v.starts_with("C.") && *v != "POSIX"); + match locale { + Some(v) if v.to_ascii_lowercase().starts_with("de") => Language::German, + _ => Language::English, + } + } + + /// Sprache des Systems + pub fn system() -> Language { + let var = |name| std::env::var(name).ok(); + Language::from_locale(&[ + var("LC_ALL"), + var("LC_MESSAGES"), + var("LANG"), + var("LANGUAGE"), + ]) + } +} + +static CURRENT: AtomicU8 = AtomicU8::new(0); + +/// Stellt die Sprache der Oberfläche um (wirkt ab dem nächsten Frame). +pub fn set_language(language: Language) { + CURRENT.store(language as u8, Ordering::Relaxed); +} + +/// Aktuelle Sprache der Oberfläche +pub fn language() -> Language { + match CURRENT.load(Ordering::Relaxed) { + 0 => Language::German, + _ => Language::English, + } +} + +fn english() -> &'static HashMap<&'static str, &'static str> { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| en::EN.iter().copied().collect()) +} + +/// Text `german` in der Sprache `language`; fehlt die Übersetzung, bleibt er deutsch. +pub fn translate(language: Language, german: &'static str) -> &'static str { + match language { + Language::German => german, + Language::English => english().get(german).copied().unwrap_or(german), + } +} + +/// Text in der aktuellen Sprache +pub fn tr(german: &'static str) -> &'static str { + translate(language(), german) +} + +/// Setzt die Werte für die Platzhalter `{name}` ein. +pub fn fill(template: &str, args: &[(&str, &dyn Display)]) -> String { + let mut out = String::with_capacity(template.len() + 16); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + match after.find('}') { + Some(close) => { + let name = &after[..close]; + match args.iter().find(|(key, _)| *key == name) { + Some((_, value)) => out.push_str(&value.to_string()), + None => out.push_str(&rest[open..open + close + 2]), + } + rest = &after[close + 1..]; + } + None => { + out.push_str(&rest[open..]); + rest = ""; + } + } + } + out.push_str(rest); + out +} + +/// Übersetzt einen deutschen Text: `t!("Abbrechen")` ergibt `&'static str`, +/// `t!("Seite {number}", number = n)` einen `String` mit eingesetzten Werten. +#[macro_export] +macro_rules! t { + ($german:literal) => { + $crate::i18n::tr($german) + }; + ($german:literal, $($name:ident = $value:expr),+ $(,)?) => { + $crate::i18n::fill( + $crate::i18n::tr($german), + &[$((stringify!($name), &$value as &dyn ::std::fmt::Display)),+], + ) + }; +} + +/// Markiert einen deutschen Text zum Übersetzen, ohne ihn schon zu übersetzen (für Tabellen; +/// angezeigt wird er mit `tr`). +#[macro_export] +macro_rules! tn { + ($german:literal) => { + $german + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Quelltexte mit übersetzbaren Texten + const SOURCES: &[(&str, &str)] = &[ + ("assets.rs", include_str!("assets.rs")), + ("autosave.rs", include_str!("autosave.rs")), + ("background.rs", include_str!("background.rs")), + ("document.rs", include_str!("document.rs")), + ("export.rs", include_str!("export.rs")), + ("file_picker.rs", include_str!("file_picker.rs")), + ("geometry.rs", include_str!("geometry.rs")), + ("library.rs", include_str!("library.rs")), + ("main.rs", include_str!("main.rs")), + ("page_export.rs", include_str!("page_export.rs")), + ("pdf_doc.rs", include_str!("pdf_doc.rs")), + ("pdf_merge.rs", include_str!("pdf_merge.rs")), + ("canvas.rs", include_str!("canvas.rs")), + ("selection.rs", include_str!("selection.rs")), + ("settings.rs", include_str!("settings.rs")), + ("storage.rs", include_str!("storage.rs")), + ("text.rs", include_str!("text.rs")), + ("ui.rs", include_str!("ui.rs")), + ("updater.rs", include_str!("updater.rs")), + ]; + + /// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen + /// (Escapes wie `\n` aufgelöst) + fn marked_texts(src: &str) -> Vec { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + let rest: String = chars[i..chars.len().min(i + 4)].iter().collect(); + let marker_len = if rest.starts_with("t!(") { + 3 + } else if rest.starts_with("tn!(") { + 4 + } else { + i += 1; + continue; + }; + // `t!(` nicht als Ende eines längeren Namens (z. B. `format!(`) zählen + if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') { + i += 1; + continue; + } + i += marker_len; + while chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + } + if chars.get(i) != Some(&'"') { + continue; + } + i += 1; + let mut text = String::new(); + while i < chars.len() && chars[i] != '"' { + if chars[i] == '\\' { + i += 1; + match chars.get(i) { + Some('n') => text.push('\n'), + // Zeilenfortsetzung: Umbruch und führende Leerzeichen entfallen + Some('\n') => { + while chars.get(i + 1).is_some_and(|c| c.is_whitespace()) { + i += 1; + } + } + Some(other) => text.push(*other), + None => {} + } + } else { + text.push(chars[i]); + } + i += 1; + } + out.push(text); + } + out + } + + fn placeholders(text: &str) -> Vec<&str> { + let mut names: Vec<&str> = text + .split('{') + .skip(1) + .filter_map(|part| part.split_once('}').map(|(name, _)| name)) + .collect(); + names.sort_unstable(); + names + } + + #[test] + fn every_marked_text_has_an_english_translation() { + let table = english(); + let mut missing: Vec = SOURCES + .iter() + .flat_map(|(file, src)| { + marked_texts(src) + .into_iter() + .filter(|text| !table.contains_key(text.as_str())) + .map(move |text| format!("{file}: {text:?}")) + }) + .collect(); + missing.sort(); + missing.dedup(); + assert!( + missing.is_empty(), + "Ohne Übersetzung:\n{}", + missing.join("\n") + ); + } + + #[test] + fn every_translation_is_used_and_keeps_its_placeholders() { + let used: std::collections::HashSet = SOURCES + .iter() + .flat_map(|(_, src)| marked_texts(src)) + .collect(); + let mut seen = std::collections::HashSet::new(); + for (german, english) in en::EN { + assert!(seen.insert(german), "Doppelt: {german:?}"); + assert!(used.contains(*german), "Nicht (mehr) verwendet: {german:?}"); + assert_eq!( + placeholders(german), + placeholders(english), + "Platzhalter verschieden: {german:?} / {english:?}" + ); + } + } + + #[test] + fn translate_falls_back_to_german() { + assert_eq!(translate(Language::German, "Abbrechen"), "Abbrechen"); + assert_eq!(translate(Language::English, "Abbrechen"), "Cancel"); + assert_eq!( + translate(Language::English, "kein bekannter Text"), + "kein bekannter Text" + ); + } + + #[test] + fn fill_replaces_named_placeholders() { + assert_eq!( + fill( + "Seite {number} von {count}", + &[("number", &3), ("count", &"7")] + ), + "Seite 3 von 7" + ); + assert_eq!(fill("{unbekannt} bleibt", &[]), "{unbekannt} bleibt"); + assert_eq!(fill("offen {", &[]), "offen {"); + } + + #[test] + fn locale_selects_language() { + let s = |v: &str| Some(v.to_string()); + assert_eq!( + Language::from_locale(&[None, None, s("de_AT.UTF-8")]), + Language::German + ); + assert_eq!( + Language::from_locale(&[s("en_US.UTF-8"), None, s("de_DE.UTF-8")]), + Language::English + ); + assert_eq!( + Language::from_locale(&[s("C"), None, s("de_DE.UTF-8")]), + Language::German + ); + assert_eq!( + Language::from_locale(&[s("C.UTF-8"), None, s("de_DE.UTF-8")]), + Language::German + ); + assert_eq!( + Language::from_locale(&[None, None, None]), + Language::English + ); + assert_eq!( + Language::from_locale(&[None, None, s("fr_FR.UTF-8")]), + Language::English + ); + } +} diff --git a/src/i18n/en.rs b/src/i18n/en.rs new file mode 100644 index 0000000..116454e --- /dev/null +++ b/src/i18n/en.rs @@ -0,0 +1,438 @@ +//! Englische Fassung der Oberflächentexte: (deutscher Text im Quelltext, englischer Text). +//! Platzhalter `{name}` müssen in beiden Fassungen gleich heißen; der Test in `i18n.rs` meldet +//! fehlende, überzählige und abweichende Einträge. + +pub const EN: &[(&str, &str)] = &[ + ("'{dir}' kann nicht geöffnet werden: {err}", "'{dir}' cannot be opened: {err}"), + ("'{name}' erfolgreich heruntergeladen!", "'{name}' downloaded successfully!"), + ("'{name}' nicht eingefügt: {err}", "'{name}' not inserted: {err}"), + ("'{title}' gelöscht", "'{title}' deleted"), + ("'{title}' importiert ({count} Seiten)", "'{title}' imported ({count} pages)"), + ("(Empfohlen für dieses System)", "(Recommended for this system)"), + ("1 Notizbuch", "1 notebook"), + ("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"), + ("Abbrechen", "Cancel"), + ("Abfrage fehlgeschlagen:", "Request failed:"), + ("Achsenkreuz", "Axis cross"), + ("Achteck (Oktagon)", "Octagon"), + ("Aktuelle Version:", "Current version:"), + ("Alle Striche radieren", "Erase whole strokes"), + ("Alle", "All"), + ("Alles gespeichert", "All saved"), + ("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"), + ("An: Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", "On: pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically.\nOff: flip page by page."), + ("ANSICHT", "VIEW"), + ("AppImage (Portabel)", "AppImage (portable)"), + ("Asset-Name darf nicht leer sein.", "Asset name must not be empty."), + ("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"), + ("Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen", "Pages with an imported PDF cannot make space"), + ("Aus manifest.json.bak wiederherstellen", "Restore from manifest.json.bak"), + ("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.", "Off: one finger pans, two fingers zoom (recommended with a pen).\nOn: one finger draws."), + ("Auswahl / Lasso (Select)", "Select / Lasso"), + ("Auswahl auf Blau umgefärbt", "Selection recolored to blue"), + ("Auswahl auf Schwarz umgefärbt", "Selection recolored to black"), + ("Auswahl in der Farbe von Stift {number} umfärben", "Recolor selection with the color of pen {number}"), + ("Auswahl um 90° gedreht", "Selection rotated by 90°"), + ("Auswahl umgefärbt", "Selection recolored"), + ("Auswählen", "Select"), + ("Auswählen: verschieben, drehen, skalieren, kopieren (V)", "Select: move, rotate, scale, copy (V)"), + ("Autosave fehlgeschlagen für Seiten {pages}: {messages}", "Autosave failed for pages {pages}: {messages}"), + ("Beschädigte Seite {id}: {msg}", "Damaged page {id}: {msg}"), + ("Bild einfügen", "Insert image"), + ("Bild eingefügt ({width} × {height} px)", "Image inserted ({width} × {height} px)"), + ("Bild ist zu groß ({size} MB, höchstens {max} MB)", "Image is too large ({size} MB, at most {max} MB)"), + ("Bild kann nicht gelesen werden: {msg}", "Cannot read image: {msg}"), + ("Bild mit Papier und Raster (200 dpi)", "Image with paper and pattern (200 dpi)"), + ("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"), + ("Bild nicht eingefügt: {err}", "Image not inserted: {err}"), + ("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"), + ("Blanko", "Blank"), + ("Blau", "Blue"), + ("Code", "Code"), + ("Code-Schrift (feste Zeichenbreite, Tab rückt ein)", "Code font (fixed width, Tab indents)"), + ("Creme", "Cream"), + ("Das PDF enthält keine Seiten", "The PDF has no pages"), + ("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"), + ("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"), + ("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."), + ("Datei existiert nicht", "File does not exist"), + ("Den Ordner gibt es nicht mehr", "The folder no longer exists"), + ("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"), + ("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"), + ("Der Ordnername ist leer", "The folder name is empty"), + ("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"), + ("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"), + ("DICKEN-PRESETS", "WIDTH PRESETS"), + ("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"), + ("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"), + ("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"), + ("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."), + ("die oberste Ebene", "the top level"), + ("Die Zwischenablage ist leer", "The clipboard is empty"), + ("Dieser Ordner ist leer.", "This folder is empty."), + ("Dokument '{title}' erstellt", "Document '{title}' created"), + ("Dokument '{title}' geöffnet", "Document '{title}' opened"), + ("Dokument in Papierkorb verschoben", "Document moved to the trash"), + ("DOKUMENT", "DOCUMENT"), + ("Dokumenten-Manager", "Document manager"), + ("Doppelpfeil", "Double arrow"), + ("Download-Verbindungsfehler: {e}", "Download connection error: {e}"), + ("Dreieck", "Triangle"), + ("Druckkurve des Stifts", "Pen pressure curve"), + ("Druckkurve zurücksetzen", "Reset pressure curve"), + ("Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. Bis dahin radieren alle Tasten.", "Press a button on the pen (above the screen) – it will appear here. Until then all buttons erase."), + ("Dunkelgrau", "Dark gray"), + ("Dunkle Stiftfarben aufhellen", "Lighten dark pen colors"), + ("Eigene Farbe wählen", "Choose a custom color"), + ("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"), + ("Ein-/Ausgabefehler: {e}", "I/O error: {e}"), + ("Einfügen (Strg+V)", "Paste (Ctrl+V)"), + ("EINGABE", "INPUT"), + ("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"), + ("Ellipse", "Ellipse"), + ("Entwicklung / Lokal", "Development / local"), + ("Export fehlgeschlagen: {err}", "Export failed: {err}"), + ("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"), + ("Farbe {number}", "Color {number}"), + ("FARBE", "COLOR"), + ("Fehler beim Beenden", "Error while quitting"), + ("Fehler beim Erstellen: {err}", "Error while creating: {err}"), + ("Fehler beim Lesen der Prüfsummendatei: {e}", "Error reading the checksum file: {e}"), + ("Fehler beim Lesen des Download-Streams: {e}", "Error reading the download stream: {e}"), + ("Fehler beim Löschen: {err}", "Error while deleting: {err}"), + ("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"), + ("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"), + ("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"), + ("Fehler beim Öffnen: {err}", "Error while opening: {err}"), + ("Fehler: {err}", "Error: {err}"), + ("Fehlermeldung: {message}", "Error message: {message}"), + ("Formen (Shape)", "Shapes"), + ("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"), + ("Fünfeck (Pentagon)", "Pentagon"), + ("Gerade", "Line"), + ("Gespeichert in: {path}", "Saved in: {path}"), + ("Gespeichert", "Saved"), + ("Gespeichert: {path}", "Saved: {path}"), + ("Gewechselt zu '{title}'", "Switched to '{title}'"), + ("Gilt für alle Seiten, auch beim Export", "Applies to all pages, also when exporting"), + ("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"), + ("Gitea Server-URL:", "Gitea server URL:"), + ("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"), + ("Graphit-Grau", "Graphite gray"), + ("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"), + ("Grün", "Green"), + ("hart", "hard"), + ("Hell", "Light"), + ("Hellgrün", "Light green"), + ("Heranzoomen (Strg++)", "Zoom in (Ctrl++)"), + ("Herauszoomen (Strg+-)", "Zoom out (Ctrl+-)"), + ("HINTERGRUND DIESER SEITE", "BACKGROUND OF THIS PAGE"), + ("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.", "Note: if the repository is private, enter a personal access token below."), + ("HOME-Umgebungsvariable ist nicht gesetzt.", "HOME environment variable is not set."), + ("HTTP {code} Weiterleitung ohne Location-Header erhalten", "HTTP {code} redirect without Location header"), + ("I/O-Fehler: {e}", "I/O error: {e}"), + ("Im Zielordner gibt es schon einen Ordner „{name}“", "A folder “{name}” already exists in the target folder"), + ("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.", "This version has no signing key for updates. Please install the update manually."), + ("In einen anderen Ordner verschieben", "Move to another folder"), + ("Inhalt nicht lesbar: {e}", "Content not readable: {e}"), + ("ink-notes ist auf dem neuesten Stand (v{version}).", "ink-notes is up to date (v{version})."), + ("ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Hintergrund: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", "ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Background: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Eraser [Ctrl+E/S] PDF export"), + ("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", "Integrity check failed for '{asset_name}': expected {expected_hash}, computed {actual_hash}. The file was deleted."), + ("Kariert (5mm)", "Grid (5mm)"), + ("Kariert", "Grid"), + ("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"), + ("kein Asset-Ordner", "no asset folder"), + ("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."), + ("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."), + ("Keine manifest.json vorhanden", "No manifest.json present"), + ("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."), + ("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"), + ("Keine Ordner oder PDF-Dateien", "No folders or PDF files"), + ("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"), + ("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"), + ("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."), + ("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", "Could not replace the AppImage atomically: {e}. The old AppImage stays unchanged."), + ("Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", "Could not create backup ({path}): {e}. Update cancelled, old AppImage unchanged."), + ("Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}", "Could not open file '{path}' to compute its checksum: {e}"), + ("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", "Could not find a unique notebook folder after 100 attempts"), + ("Konnte temporäre Update-Datei nicht schreiben: {e}", "Could not write temporary update file: {e}"), + ("Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}", "Could not create update cache folder ({path}): {e}"), + ("Konnte xdg-open nicht ausführen: {e}", "Could not run xdg-open: {e}"), + ("Konnte Zieldatei '{path}' nicht erstellen: {e}", "Could not create target file '{path}': {e}"), + ("Konnte {name} nicht dekodieren: {e}.", "Could not decode {name}: {e}."), + ("Konnte {name} nicht laden: {e}.", "Could not load {name}: {e}."), + ("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.", "Could not determine the parent folder of the AppImage."), + ("Koordinatensystem", "Coordinate system"), + ("Kopieren (Strg+C)", "Copy (Ctrl+C)"), + ("Korrektur-Rot", "Correction red"), + ("Kreis", "Circle"), + ("Kräftig", "Strong"), + ("Lade '{name}' herunter...", "Downloading '{name}'..."), + ("Leere Seite direkt nach der aktuellen Seite", "Blank page right after the current page"), + ("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden.", "Lighter pressure counts as no pressure (thinnest line).\nHelps with pens that already report pressure while hovering."), + ("Leuchtet grün, solange die Taste gedrückt ist", "Lights up green while the button is pressed"), + ("Liegt bereits hier", "Already here"), + ("Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; Striche an einer Kante werden gerade", "Ruler (L): drag the surface = move, handle ⟳ = rotate; strokes along an edge become straight"), + ("Liniert (7.5mm)", "Lined (7.5mm)"), + ("Liniert", "Lined"), + ("Links", "Left"), + ("Löschen (Entf)", "Delete (Del)"), + ("Löschen", "Delete"), + ("Löscht die aktuelle Seite; danach im Menü wiederherstellbar", "Deletes the current page; can be restored from the menu afterwards"), + ("Manifest aus Backup wiederhergestellt", "Manifest restored from backup"), + ("Manifest: {msg}", "Manifest: {msg}"), + ("Manifestdatei nicht gefunden: {path}", "Manifest file not found: {path}"), + ("Mindestdruck", "Minimum pressure"), + ("Mit dem Finger zeichnen", "Draw with finger"), + ("Mittel", "Medium"), + ("Name …", "Name …"), + ("Neon-Gelb", "Neon yellow"), + ("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"), + ("Neue Seite nach dieser einfügen", "Insert a new page after this one"), + ("Neue Version verfügbar: {version}", "New version available: {version}"), + ("Neuen Ordner hier anlegen (z. B. Semester oder Fach)", "Create a new folder here (e.g. semester or subject)"), + ("Neues Notizbuch in diesem Ordner anlegen und öffnen", "Create a new notebook in this folder and open it"), + ("Nichts", "Nothing"), + ("Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.", "No page has a title yet. Enter a title for the current page above."), + ("Normal", "Normal"), + ("Normale Schrift", "Normal font"), + ("Notfall-Export fehlgeschlagen: {err}", "Emergency export failed: {err}"), + ("Notfall-Export", "Emergency export"), + ("Notiz-Schwarz", "Note black"), + ("Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet", "Notebook '{path}' is already used by another instance"), + ("Notizbuch '{title}' konnte nicht geladen werden – neues Notizbuch angelegt", "Notebook '{title}' could not be loaded – created a new notebook"), + ("Notizbuch enthält keine Seiten", "Notebook has no pages"), + ("Notizbuch in Papierkorb verschieben", "Move notebook to the trash"), + ("Notizbuch ist gesperrt: {msg}", "Notebook is locked: {msg}"), + ("Notizbuch wiederhergestellt", "Notebook restored"), + ("Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})", "Notebook was created with a newer version (format version {found}, supported up to {supported})"), + ("Notizbuch", "Notebook"), + ("Notizbücher verwalten (aktuell: {title})", "Manage notebooks (current: {title})"), + ("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden", "Only images (PNG, JPEG, GIF, WebP, BMP) and PDFs can be inserted"), + ("Nur Textmarker radieren – Handschrift darunter bleibt", "Erase highlighter only – handwriting underneath stays"), + ("Nächste Seite (Bild ab)", "Next page (Page Down)"), + ("Oktaeder (3D-Kantenmodell)", "Octahedron (3D wireframe)"), + ("Ordner löschen (die Notizbücher bleiben erhalten)", "Delete folder (the notebooks are kept)"), + ("Ordner löschen", "Delete folder"), + ("Ordner nicht gespeichert: {e}", "Folder not saved: {e}"), + ("Ordner samt Inhalt in einen anderen Ordner verschieben", "Move folder with its contents to another folder"), + ("Ordner umbenennen", "Rename folder"), + ("Ordner „{name}“ angelegt", "Folder “{name}” created"), + ("Ordner „{name}“ gelöscht, sein Inhalt liegt eine Ebene höher", "Folder “{name}” deleted, its contents moved up one level"), + ("Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine Ebene nach oben, gelöscht wird kein Notizbuch.", "Delete folder “{name}”? Its notebooks and subfolders move up one level, no notebook is deleted."), + ("Ordner „{name}“ verschoben in {target}", "Folder “{name}” moved to {target}"), + ("Ordner: {path}", "Folder: {path}"), + ("Ordnernamen dürfen kein „/“ enthalten", "Folder names must not contain “/”"), + ("PAPIERFARBE (NUR ANZEIGE)", "PAPER COLOR (DISPLAY ONLY)"), + ("Papierkorb-Eintrag '{path}' existiert nicht", "Trash entry '{path}' does not exist"), + ("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"), + ("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"), + ("Pastell-Cyan", "Pastel cyan"), + ("PDF importieren", "Import PDF"), + ("PDF kann nicht geöffnet werden", "Cannot open PDF"), + ("PDF nicht importiert: {err}", "PDF not imported: {err}"), + ("PDF nicht lesbar: {e}", "PDF not readable: {e}"), + ("PDF ohne Seite", "PDF without a page"), + ("PDF-Export läuft…", "PDF export running…"), + ("PDF-Render-Thread beendet", "PDF render thread stopped"), + ("Personal Access Token (optional für private Repos):", "Personal access token (optional for private repos):"), + ("Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden", "Path '{path}' is a symlink and must not be deleted"), + ("Pfad '{path}' liegt nicht direkt unter Basis-Verzeichnis '{base}'", "Path '{path}' is not directly inside the base folder '{base}'"), + ("Pfad in Statusleiste anzeigen", "Show path in the status bar"), + ("Pfeil", "Arrow"), + ("Pixeldaten passen nicht zur Bildgröße", "Pixel data does not match the image size"), + ("Platz schaffen", "Make space"), + ("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf die folgenden Seiten; nach oben schließt leeren Platz", "Make space: tap the page and drag down – everything below moves along, also onto the following pages; dragging up closes empty space"), + ("Polygon", "Polygon"), + ("Prüfe Gitea-Releases...", "Checking Gitea releases..."), + ("Punkte", "Dots"), + ("Punktraster (5mm)", "Dot grid (5mm)"), + ("Quell-PDF fehlt", "Source PDF missing"), + ("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"), + ("Radieren", "Erase"), + ("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"), + ("Radierer (Eraser)", "Eraser"), + ("Raute (Rhombus)", "Rhombus"), + ("Rechteck", "Rectangle"), + ("Rechts", "Right"), + ("Release enthält keine {name}.", "Release contains no {name}."), + ("Release- & Update-Center", "Release & update center"), + ("Rosa", "Pink"), + ("Rückgängig (Strg+Z)", "Undo (Ctrl+Z)"), + ("Schließen (Esc)", "Close (Esc)"), + ("Schriftgröße {size} pt", "Font size {size} pt"), + ("Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und als PDF exportiert werden immer die Originalfarben.", "Black becomes white, dark blue becomes light blue. Saving and PDF export always use the original colors."), + ("Schwarz", "Black"), + ("Sechseck (Hexagon)", "Hexagon"), + ("Seite als", "Page as"), + ("Seite ist schreibgeschützt", "Page is read-only"), + ("Seite konnte nicht geladen werden", "Page could not be loaded"), + ("Seite wird exportiert …", "Exporting page …"), + ("Seite wählen", "Choose page"), + ("Seite {id} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", "Page {id} is not loaded – export cancelled (call storage::load_all_pages first)"), + ("Seite {id} konnte nicht fehlerfrei geladen werden!", "Page {id} could not be loaded without errors!"), + ("Seite {id} nicht gefunden", "Page {id} not found"), + ("Seite {id}: {msg}", "Page {id}: {msg}"), + ("Seite {number} / {count}", "Page {number} / {count}"), + ("Seite {number} eingefügt", "Page {number} inserted"), + ("Seite {number} fehlt im PDF", "Page {number} missing in the PDF"), + ("Seite {number} fehlt im Quell-PDF", "Page {number} missing in the source PDF"), + ("Seite {number} gelöscht – im Menü ☰ wiederherstellbar", "Page {number} deleted – can be restored from the ☰ menu"), + ("Seite {number} mit allem Inhalt löschen?", "Delete page {number} with all its content?"), + ("Seite {number} wiederhergestellt", "Page {number} restored"), + ("Seite {number} – {title}", "Page {number} – {title}"), + ("Seite {number}", "Page {number}"), + ("Seiten ({count})", "Pages ({count})"), + ("Seiten durchgehend scrollen", "Scroll pages continuously"), + ("Serialisierungsfehler: {e}", "Serialization error: {e}"), + ("Server-Einstellungen gespeichert", "Server settings saved"), + ("Server:", "Server:"), + ("Signaturprüfung von {file} fehlgeschlagen: {e}.", "Signature check of {file} failed: {e}."), + ("Speicherfehler!", "Save error!"), + ("Speicherfehler: {messages}", "Save error: {messages}"), + ("Speichert …", "Saving …"), + ("Speichert...", "Saving..."), + ("Sprache der Oberfläche", "Interface language"), + ("Standard-Notizbuch erstellt", "Default notebook created"), + ("Standard-Notizbuch", "Default notebook"), + ("STATUS", "STATUS"), + ("Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke", "Pen (B/U/R for colors) – tap again for color & width"), + ("Stift (Pen)", "Pen"), + ("Stift {number} ({width} mm) – erneut tippen für Farbe & Stärke; Änderungen merkt sich der Stift", "Pen {number} ({width} mm) – tap again for color & width; the pen remembers changes"), + ("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"), + ("Stift {number}", "Pen {number}"), + ("Stift", "Pen"), + ("STUFENLOSE STÄRKE", "STEPLESS WIDTH"), + ("Stärke auf {width} setzen", "Set width to {width}"), + ("System", "System"), + ("System-Paket (.deb)", "System package (.deb)"), + ("Tafel-Grün", "Chalkboard green"), + ("Taste {number}", "Button {number}"), + ("Tasten am Stift", "Pen buttons"), + ("Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)", "Erase partial stroke – remove only the part you wipe over (shapes completely)"), + ("Text (T)", "Text (T)"), + ("Text (T): ins Blatt tippen und schreiben, vorhandenen Text antippen zum Ändern; Esc beendet", "Text (T): tap the page and write, tap existing text to edit it; Esc finishes"), + ("Text eingefügt – mit dem Text-Werkzeug (T) änderbar", "Text pasted – editable with the text tool (T)"), + ("Textfarbe {number}", "Text color {number}"), + ("Textmarker (Highlighter)", "Highlighter"), + ("Textmarker – erneut tippen für Farbe & Stärke", "Highlighter – tap again for color & width"), + ("Textmarker – Farbe {number}", "Highlighter – color {number}"), + ("Textmarker", "Highlighter"), + ("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.", "Tip: paste screenshots with Ctrl+V or drag image files into the window."), + ("Titel umbenennen", "Rename title"), + ("TITEL VON SEITE {number}", "TITLE OF PAGE {number}"), + ("Trotzdem beenden", "Quit anyway"), + ("Trotzdem beenden?", "Quit anyway?"), + ("Um 90° drehen (R)", "Rotate by 90° (R)"), + ("Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\nWenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.", "To prevent data loss, this page is read-only.\nIf you want to edit this page, you can remove the write protection."), + ("Umbenannt in '{title}'", "Renamed to '{title}'"), + ("Ungespeichert", "Unsaved"), + ("Ungespeicherte Änderungen konnten nicht gesichert werden:", "Unsaved changes could not be saved:"), + ("Ungespeicherte Änderungen", "Unsaved changes"), + ("Ungültige Server-URL '{url}': {e}", "Invalid server URL '{url}': {e}"), + ("Ungültige URL '{initial_url_str}': {e}", "Invalid URL '{initial_url_str}': {e}"), + ("Ungültiger AppImage-Dateiname.", "Invalid AppImage file name."), + ("Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'", "Invalid asset name (contains forbidden path components): '{raw_name}'"), + ("Ungültiger Bildverweis '{name}'", "Invalid image reference '{name}'"), + ("Ungültiger Dateiname: '{raw_name}'", "Invalid file name: '{raw_name}'"), + ("Ungültiger oder versteckter Asset-Name: '{raw_name}'", "Invalid or hidden asset name: '{raw_name}'"), + ("Ungültiger PDF-Verweis '{key}'", "Invalid PDF reference '{key}'"), + ("Ungültiger Pfad (Path Traversal): {msg}", "Invalid path (path traversal): {msg}"), + ("Ungültiger Update-Signaturschlüssel: {e}.", "Invalid update signing key: {e}."), + ("Ungültiges Manifest: {msg}", "Invalid manifest: {msg}"), + ("Ungültiges Weiterleitungsziel '{loc}': {e}", "Invalid redirect target '{loc}': {e}"), + ("Universitäts-Blau", "University blue"), + ("Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", "Insecure protocol '{scheme}' rejected. HTTPS is required (e.g. https://gitea.pansi.eu)."), + ("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", "Insecure protocol '{scheme}' for '{url}' rejected. HTTPS is required."), + ("Update gestartet / geöffnet", "Update started / opened"), + ("Update-Fehler: {err}", "Update error: {err}"), + ("Vektorgrafik, z. B. für Inkscape oder LaTeX", "Vector graphics, e.g. for Inkscape or LaTeX"), + ("VERFÜGBARE ARTEFAKTE:", "AVAILABLE ARTIFACTS:"), + ("Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)", "Forget (appears again on the next press, then erasing)"), + ("Verschoben in {target}", "Moved to {target}"), + ("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"), + ("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"), + ("Vorherige Seite (Bild auf)", "Previous page (Page Up)"), + ("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"), + ("weich", "soft"), + ("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."), + ("Weitere Funktionen – Update verfügbar", "More – update available"), + ("Weitere Funktionen", "More"), + ("Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen", "More: PDF, background, zoom, settings"), + ("Weiß", "White"), + ("WERKZEUGLEISTE", "TOOLBAR"), + ("Wiederherstellung fehlgeschlagen: {err}", "Restore failed: {err}"), + ("Wiederholen (Strg+Y)", "Redo (Ctrl+Y)"), + ("Wiederholen", "Retry"), + ("Worker panic beim Speichern der Seite", "Worker panic while saving the page"), + ("Worker panic beim Speichern des Manifests", "Worker panic while saving the manifest"), + ("z. B. Kapitel 2 – Integrale", "e.g. Chapter 2 – Integrals"), + ("Zielseite fehlt", "Target page missing"), + ("ZOOM", "ZOOM"), + ("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."), + ("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."), + ("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"), + ("{count} Objekt(e) eingefügt", "{count} object(s) pasted"), + ("{count} Objekt(e) gelöscht", "{count} object(s) deleted"), + ("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"), + ("{count} Objekt(e) kopiert", "{count} object(s) copied"), + ("{count} Objekte | [R] Drehen [Entf] Löschen", "{count} objects | [R] Rotate [Del] Delete"), + ("{count} Seite(n)", "{count} page(s)"), + ("{e} Download abgelehnt.", "{e} Download refused."), + ("{file} ist ungültig: {e}.", "{file} is invalid: {e}."), + ("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)", "{mm} mm of space made (undo with Ctrl+Z)"), + ("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)", "{mm} mm of space closed (undo with Ctrl+Z)"), + ("{n} Notizbücher", "{n} notebooks"), + ("{shape_name} ausgerichtet! (Undo mit Strg+Z)", "{shape_name} straightened! (undo with Ctrl+Z)"), + ("{shape} eingerastet!", "{shape} snapped!"), + ("{title}_Seite_{number}{suffix}", "{title}_page_{number}{suffix}"), + ("Änderungen:", "Changes:"), + ("Übergeordneter Ordner", "Parent folder"), + ("Übergeordnetes Verzeichnis nicht gefunden", "Parent folder not found"), + ("„{title}“ verschieben nach:", "Move “{title}” to:"), + ("↩ Rückgängig", "↩ Undo"), + ("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"), + ("⏷ {mm} mm Platz", "⏷ {mm} mm space"), + ("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"), + ("⚠ Beschädigt", "⚠ Damaged"), + ("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"), + ("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"), + ("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"), + ("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"), + ("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"), + ("⚫ Aktiv", "⚫ Active"), + ("✔ Update bereit", "✔ Update ready"), + ("➕ Neue Seite einfügen", "➕ Insert new page"), + ("➕ Notizbuch", "➕ Notebook"), + ("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"), + ("⬇ Downloads", "⬇ Downloads"), + ("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"), + ("🏠 Persönlicher Ordner", "🏠 Home folder"), + ("💾 Einstellungen speichern", "💾 Save settings"), + ("📁 Ordner", "📁 Folder"), + ("📂 Im Dateimanager anzeigen", "📂 Show in file manager"), + ("📂 Ordner öffnen", "📂 Open folder"), + ("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"), + ("📄 PDF importieren …", "📄 Import PDF …"), + ("📄 PDF importieren", "📄 Import PDF"), + ("📋 Einfügen", "📋 Paste"), + ("📑 Inhalt", "📑 Contents"), + ("📚 Alle", "📚 All"), + ("📚 Notizbücher", "📚 Notebooks"), + ("📚 Oberste Ebene", "📚 Top level"), + ("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"), + ("🔄 Suche nach Updates …", "🔄 Checking for updates …"), + ("🔄 Updates & Versionen", "🔄 Updates & versions"), + ("🔄 Backup", "🔄 Backup"), + ("🔄 Nach Updates suchen", "🔄 Check for updates"), + ("🖥 Schreibtisch", "🖥 Desktop"), + ("🖼 Bild aus Datei …", "🖼 Image from file …"), + ("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"), + ("🖼 Kacheln", "🖼 Tiles"), + ("🖼 Bild einfügen", "🖼 Insert image"), + ("🖼 Bilder", "🖼 Pictures"), + ("🗑 Seite {number} löschen …", "🗑 Delete page {number} …"), + ("🚀 Update {version} verfügbar", "🚀 Update {version} available"), + ("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"), + ("🚀 Updates & Versionen", "🚀 Updates & versions"), +]; diff --git a/src/lib.rs b/src/lib.rs index f89b190..6ab5342 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod file_picker; pub mod filter; pub mod geometry; pub mod history; +pub mod i18n; pub mod image_layer; pub mod library; pub mod osk; diff --git a/src/library.rs b/src/library.rs index 824acb8..10fea62 100644 --- a/src/library.rs +++ b/src/library.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::storage::{self, StorageError}; +use crate::t; /// Oberste Ebene. pub const ROOT: &str = ""; @@ -62,14 +63,15 @@ fn is_within(path: &str, folder: &str) -> bool { pub fn validate_name(name: &str) -> Result { let name = name.trim(); if name.is_empty() { - return Err("Der Ordnername ist leer".to_string()); + return Err(t!("Der Ordnername ist leer").to_string()); } if name.contains('/') { - return Err("Ordnernamen dürfen kein „/“ enthalten".to_string()); + return Err(t!("Ordnernamen dürfen kein „/“ enthalten").to_string()); } if name.chars().count() > MAX_NAME_CHARS { - return Err(format!( - "Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)" + return Err(t!( + "Der Ordnername ist zu lang (höchstens {max} Zeichen)", + max = MAX_NAME_CHARS )); } Ok(name.to_string()) @@ -161,11 +163,11 @@ impl Library { pub fn create_folder(&mut self, parent: &str, name: &str) -> Result { let name = validate_name(name)?; if !parent.is_empty() && !self.folders.contains(parent) { - return Err("Der übergeordnete Ordner existiert nicht mehr".to_string()); + return Err(t!("Der übergeordnete Ordner existiert nicht mehr").to_string()); } let path = join(parent, &name); if !self.folders.insert(path.clone()) { - return Err(format!("Den Ordner „{name}“ gibt es hier schon")); + return Err(t!("Den Ordner „{name}“ gibt es hier schon", name = name)); } Ok(path) } @@ -175,14 +177,17 @@ impl Library { pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result { let new_name = validate_name(new_name)?; if !self.folders.contains(path) { - return Err("Den Ordner gibt es nicht mehr".to_string()); + return Err(t!("Den Ordner gibt es nicht mehr").to_string()); } let new_path = join(parent_folder(path), &new_name); if new_path == path { return Ok(new_path); } if self.folders.contains(&new_path) { - return Err(format!("Den Ordner „{new_name}“ gibt es hier schon")); + return Err(t!( + "Den Ordner „{name}“ gibt es hier schon", + name = new_name + )); } self.move_contents(path, &new_path); Ok(new_path) @@ -240,13 +245,13 @@ impl Library { /// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner. pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result { if !self.folders.contains(path) { - return Err("Den Ordner gibt es nicht mehr".to_string()); + return Err(t!("Den Ordner gibt es nicht mehr").to_string()); } if !new_parent.is_empty() && !self.folders.contains(new_parent) { - return Err("Den Zielordner gibt es nicht mehr".to_string()); + return Err(t!("Den Zielordner gibt es nicht mehr").to_string()); } if is_within(new_parent, path) { - return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string()); + return Err(t!("Ein Ordner kann nicht in sich selbst verschoben werden").to_string()); } let name = folder_name(path); let new_path = join(new_parent, name); @@ -254,7 +259,10 @@ impl Library { return Ok(new_path); } if self.folders.contains(&new_path) { - return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“")); + return Err(t!( + "Im Zielordner gibt es schon einen Ordner „{name}“", + name = name + )); } self.move_contents(path, &new_path); Ok(new_path) @@ -276,7 +284,7 @@ impl Library { return Ok(()); } if !self.folders.contains(folder) { - return Err("Den Ordner gibt es nicht mehr".to_string()); + return Err(t!("Den Ordner gibt es nicht mehr").to_string()); } self.notebooks .insert(notebook_id.to_string(), folder.to_string()); diff --git a/src/main.rs b/src/main.rs index a37dd37..49b5ea0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; +use ink_notes::i18n::{self, Language}; use ink_notes::image_layer::TextureSource; use ink_notes::library::Library; use ink_notes::osk::{self, KeyboardRequester}; @@ -36,6 +37,7 @@ use ink_notes::settings::{ SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS, }; use ink_notes::storage::{self, NotebookManifest}; +use ink_notes::t; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; @@ -70,7 +72,7 @@ fn space_press( let index = notebook.current_page_index; if notebook.space_run(index).is_none() { ui_state.set_export_message( - "Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(), + t!("Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen").to_string(), ); return false; } @@ -118,9 +120,9 @@ fn space_release( canvas.sync_page(notebook.current_page_mut()); let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round(); ui_state.set_export_message(if dy > 0.0 { - format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)") + t!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)", mm = mm) } else { - format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)") + t!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)", mm = mm) }); } } @@ -183,7 +185,7 @@ fn change_library( library .save(base_dir) .map(|()| value) - .map_err(|e| format!("Ordner nicht gespeichert: {e}")) + .map_err(|e| t!("Ordner nicht gespeichert: {e}", e = e)) }); ui_state.library = Library::load(base_dir); match result { @@ -204,7 +206,7 @@ fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_st "[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display() ); - ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}")); + ui_state.set_export_message(t!("Einstellung nicht gespeichert: {err}", err = err)); } } } @@ -218,10 +220,10 @@ fn update_window_title( ) { let page = notebook.current_page(); let bg_name = match page.background { - PageBackground::Blank => "Blanko", - PageBackground::Grid5mm => "Kariert (5mm)", - PageBackground::Lined => "Liniert (7.5mm)", - PageBackground::DotGrid => "Punktraster (5mm)", + PageBackground::Blank => t!("Blanko"), + PageBackground::Grid5mm => t!("Kariert (5mm)"), + PageBackground::Lined => t!("Liniert (7.5mm)"), + PageBackground::DotGrid => t!("Punktraster (5mm)"), }; let page_title = page @@ -229,14 +231,14 @@ fn update_window_title( .as_deref() .map(|t| format!(" – {t}")) .unwrap_or_default(); - let title = format!( - "ink-notes | [{}] {}{} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", - manifest.title, - notebook.osd_text(), - page_title, - status.display_text(), - tool.name(), - bg_name + let title = t!( + "ink-notes | [{notebook}] {page}{page_title} | [{status}] | {tool} | Hintergrund: {background} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", + notebook = manifest.title, + page = notebook.osd_text(), + page_title = page_title, + status = status.display_text(), + tool = tool.name(), + background = bg_name ); window.set_title(&title); } @@ -580,9 +582,10 @@ fn insert_image_on_page( autosave.mark_dirty(notebook, manifest); selection_state.select_single(id); ui_state.active_tool = ActiveTool::Select; - ui_state.set_export_message(format!( - "Bild eingefügt ({} × {} px)", - imported.width, imported.height + ui_state.set_export_message(t!( + "Bild eingefügt ({width} × {height} px)", + width = imported.width, + height = imported.height )); } @@ -812,7 +815,7 @@ fn insert_text_on_page( autosave.mark_dirty(notebook, manifest); selection_state.select_single(id); ui_state.active_tool = ActiveTool::Select; - ui_state.set_export_message("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"); + ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar")); } /// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn @@ -911,7 +914,7 @@ fn texture_for( let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key) .map(|(asset, page, px)| (assets.path_of(asset), page, px)) else { - return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'")); + return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key)); }; match pdf_renderer.take(key, path, page, max_pixels) { Some(result) => result, @@ -940,9 +943,12 @@ fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView { fn copy_message(count: usize, as_image: bool) -> String { if as_image { - format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme") + t!( + "{count} Objekt(e) kopiert – auch als Bild für andere Programme", + count = count + ) } else { - format!("{count} Objekt(e) kopiert") + t!("{count} Objekt(e) kopiert", count = count) } } @@ -965,7 +971,15 @@ fn start_page_export( ui::PageExportKind::PngTransparent => ("_transparent", "png"), ui::PageExportKind::Svg => ("", "svg"), }; - let path = export::resolve_export_path(&format!("{title}_Seite_{number}{suffix}"), ext); + let path = export::resolve_export_path( + &t!( + "{title}_Seite_{number}{suffix}", + title = title, + number = number, + suffix = suffix + ), + ext, + ); std::thread::spawn(move || { let assets = AssetStore::new(&storage_dir); let bytes = match kind { @@ -1037,7 +1051,9 @@ fn paste( ui_state, ); } - Err(err) => ui_state.set_export_message(format!("Bild nicht eingefügt: {err}")), + Err(err) => { + ui_state.set_export_message(t!("Bild nicht eingefügt: {err}", err = err)) + } } return; } @@ -1091,9 +1107,9 @@ fn paste( canvas.sync_page(notebook.current_page_mut()); manifest.next_stroke_id = notebook.next_stroke_id; autosave.mark_dirty(notebook, manifest); - ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); + ui_state.set_export_message(t!("{count} Objekt(e) eingefügt", count = new_ids.len())); } else { - ui_state.set_export_message("Die Zwischenablage ist leer".to_string()); + ui_state.set_export_message(t!("Die Zwischenablage ist leer").to_string()); } } @@ -1111,7 +1127,7 @@ fn insert_image_file( ui_state: &mut UiState, ) { if notebook.current_page().is_readonly() { - ui_state.set_export_message("Seite ist schreibgeschützt".to_string()); + ui_state.set_export_message(t!("Seite ist schreibgeschützt").to_string()); return; } let result = std::fs::read(path) @@ -1132,16 +1148,26 @@ fn insert_image_file( selection_state, ui_state, ), - Err(err) => ui_state.set_export_message(format!( - "'{}' nicht eingefügt: {err}", - path.file_name() + Err(err) => ui_state.set_export_message(t!( + "'{name}' nicht eingefügt: {err}", + name = path + .file_name() .map(|n| n.to_string_lossy()) - .unwrap_or_default() + .unwrap_or_default(), + err = err )), } } fn main() -> Result<(), Box> { + // Gespeicherte UI-Einstellungen; zuerst, damit alle Texte in der gewählten Sprache sind + let ui_settings_path = UiSettings::default_path(); + let mut ui_settings = ui_settings_path + .as_deref() + .map(UiSettings::load_from) + .unwrap_or_default(); + i18n::set_language(ui_settings.language.unwrap_or_else(Language::system)); + // Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil let mut event_loop_builder = EventLoopBuilder::new(); match platform::display_backend_from_env() { @@ -1214,7 +1240,7 @@ fn main() -> Result<(), Box> { { (first.id.clone(), first.title.clone()) } else { - ("default".to_string(), "Standard-Notizbuch".to_string()) + ("default".to_string(), t!("Standard-Notizbuch").to_string()) }; let mut storage_dir = storage_base_dir.join(&initial_id); @@ -1230,8 +1256,9 @@ fn main() -> Result<(), Box> { "[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). '{}' bleibt unverändert, erstelle neues Notizbuch.", storage_dir.display() ); - startup_warning = Some(format!( - "Notizbuch '{initial_title}' konnte nicht geladen werden – neues Notizbuch angelegt" + startup_warning = Some(t!( + "Notizbuch '{title}' konnte nicht geladen werden – neues Notizbuch angelegt", + title = initial_title )); let (nb, m, dir) = storage::create_new_notebook(&storage_base_dir, &initial_title)?; storage_dir = dir; @@ -1279,13 +1306,8 @@ fn main() -> Result<(), Box> { ); let mut ui_state = UiState::new(); - // Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste) - let ui_settings_path = UiSettings::default_path(); - let mut ui_settings = ui_settings_path - .as_deref() - .map(UiSettings::load_from) - .unwrap_or_default(); ui_state.sidebar_side = ui_settings.sidebar_side; + ui_state.language = ui_settings.language; ui_state.paper_theme = ui_settings.paper_theme; ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors; ui_state.pattern_color = ui_settings.pattern_color; @@ -1326,7 +1348,7 @@ fn main() -> Result<(), Box> { Err(storage::StorageError::AlreadyLocked(msg)) => { eprintln!("[Storage-Lock] {msg}"); autosave.set_read_only(true); - ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)"); + ui_state.set_export_message(t!("⚠ In anderer Instanz geöffnet (Schreibschutz)")); None } Err(e) => { @@ -1484,8 +1506,8 @@ fn main() -> Result<(), Box> { while let Ok(result) = export_rx.try_recv() { match result { - Ok(path) => ui_state.set_export_message(format!("Gespeichert: {}", path.display())), - Err(err) => ui_state.set_export_message(format!("Export fehlgeschlagen: {err}")), + Ok(path) => ui_state.set_export_message(t!("Gespeichert: {path}", path = path.display())), + Err(err) => ui_state.set_export_message(t!("Export fehlgeschlagen: {err}", err = err)), } window.request_redraw(); } @@ -1869,7 +1891,7 @@ fn main() -> Result<(), Box> { let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color); canvas.set_live_shape_mesh(samples, vertices); - ui_state.set_export_message(format!("{} eingerastet!", shape.name())); + ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name())); window.request_redraw(); } } @@ -1993,7 +2015,7 @@ fn main() -> Result<(), Box> { ); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); } else { - ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden".to_string()); + ui_state.set_export_message(t!("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden").to_string()); } window.request_redraw(); } @@ -2176,7 +2198,7 @@ fn main() -> Result<(), Box> { if !removed.is_empty() { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); + ui_state.set_export_message(t!("{count} Objekt(e) gelöscht", count = removed.len())); window.request_redraw(); } } @@ -2186,12 +2208,12 @@ fn main() -> Result<(), Box> { Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { match autosave.flush() { Ok(()) => { - ui_state.set_export_message("Gespeichert"); + ui_state.set_export_message(t!("Gespeichert")); } Err(err) => { - ui_state.set_export_message(format!( - "Speicherfehler: {}", - err.messages.join(", ") + ui_state.set_export_message(t!( + "Speicherfehler: {messages}", + messages = err.messages.join(", ") )); } } @@ -2208,7 +2230,7 @@ fn main() -> Result<(), Box> { // PDF-Export: Strg + E Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { start_pdf_export(¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); - ui_state.set_export_message("PDF-Export läuft…"); + ui_state.set_export_message(t!("PDF-Export läuft…")); window.request_redraw(); } @@ -2245,7 +2267,7 @@ fn main() -> Result<(), Box> { if selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); + ui_state.set_export_message(t!("Auswahl auf Schwarz umgefärbt").to_string()); window.request_redraw(); } } else { @@ -2265,7 +2287,7 @@ fn main() -> Result<(), Box> { if selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); + ui_state.set_export_message(t!("Auswahl auf Blau umgefärbt").to_string()); window.request_redraw(); } } else { @@ -2284,7 +2306,7 @@ fn main() -> Result<(), Box> { if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); + ui_state.set_export_message(t!("Auswahl um 90° gedreht").to_string()); window.request_redraw(); } } else { @@ -2531,7 +2553,7 @@ fn main() -> Result<(), Box> { notebook.current_page_mut().replace_stroke(old_id, new_stroke); canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); + ui_state.set_export_message(t!("{shape_name} ausgerichtet! (Undo mit Strg+Z)", shape_name = shape_name)); window.request_redraw(); return; } @@ -3061,7 +3083,7 @@ fn main() -> Result<(), Box> { && selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message("Auswahl umgefärbt".to_string()); + ui_state.set_export_message(t!("Auswahl umgefärbt").to_string()); } match ui_state.active_tool { ActiveTool::Pen | ActiveTool::Shape => { @@ -3096,7 +3118,7 @@ fn main() -> Result<(), Box> { if !removed.is_empty() { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); + ui_state.set_export_message(t!("{count} Objekt(e) gelöscht", count = removed.len())); window.request_redraw(); } } @@ -3136,7 +3158,7 @@ fn main() -> Result<(), Box> { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { canvas.sync_page(notebook.current_page_mut()); autosave.mark_dirty(¬ebook, &mut manifest); - ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); + ui_state.set_export_message(t!("Auswahl um 90° gedreht").to_string()); window.request_redraw(); } } @@ -3208,7 +3230,7 @@ fn main() -> Result<(), Box> { } UiAction::ExportPage(kind) => { start_page_export(kind, ¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); - ui_state.set_export_message("Seite wird exportiert …".to_string()); + ui_state.set_export_message(t!("Seite wird exportiert …").to_string()); window.request_redraw(); } UiAction::SetPageTitle { page_id, title } => { @@ -3238,7 +3260,7 @@ fn main() -> Result<(), Box> { &mut ui_state, &window, ); - ui_state.set_export_message(format!("Seite {} eingefügt", index + 1)); + ui_state.set_export_message(t!("Seite {number} eingefügt", number = index + 1)); } UiAction::DeleteCurrentPage => { let index = notebook.current_page_index; @@ -3250,9 +3272,9 @@ fn main() -> Result<(), Box> { manifest.sync_from_notebook(¬ebook); autosave.mark_manifest_dirty(&manifest); last_deleted_page = Some((manifest.id.clone(), index, page)); - ui_state.set_export_message(format!( - "Seite {} gelöscht – im Menü ☰ wiederherstellbar", - index + 1 + ui_state.set_export_message(t!( + "Seite {number} gelöscht – im Menü ☰ wiederherstellbar", + number = index + 1 )); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); @@ -3268,7 +3290,7 @@ fn main() -> Result<(), Box> { // Die Seitendatei neu schreiben (sie kann inzwischen fehlen) autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_manifest_dirty(&manifest); - ui_state.set_export_message(format!("Seite {} wiederhergestellt", index + 1)); + ui_state.set_export_message(t!("Seite {number} wiederhergestellt", number = index + 1)); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } else { @@ -3310,7 +3332,7 @@ fn main() -> Result<(), Box> { } UiAction::ExportPdf => { start_pdf_export(¬ebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone()); - ui_state.set_export_message("PDF-Export läuft…"); + ui_state.set_export_message(t!("PDF-Export läuft…")); window.request_redraw(); } @@ -3318,9 +3340,9 @@ fn main() -> Result<(), Box> { if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| { library.create_folder(&parent, &name) }) { - ui_state.set_export_message(format!( - "Ordner „{}“ angelegt", - ink_notes::library::folder_name(&path) + ui_state.set_export_message(t!( + "Ordner „{name}“ angelegt", + name = ink_notes::library::folder_name(&path) )); } window.request_redraw(); @@ -3344,9 +3366,9 @@ fn main() -> Result<(), Box> { }) .is_some() { - ui_state.set_export_message(format!( - "Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher", - ink_notes::library::folder_name(&path) + ui_state.set_export_message(t!( + "Ordner „{name}“ gelöscht, sein Inhalt liegt eine Ebene höher", + name = ink_notes::library::folder_name(&path) )); } window.request_redraw(); @@ -3361,13 +3383,14 @@ fn main() -> Result<(), Box> { ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]); } let target = if new_parent.is_empty() { - "die oberste Ebene".to_string() + t!("die oberste Ebene").to_string() } else { format!("„{}“", ink_notes::library::folder_name(&new_parent)) }; - ui_state.set_export_message(format!( - "Ordner „{}“ verschoben in {target}", - ink_notes::library::folder_name(&path) + ui_state.set_export_message(t!( + "Ordner „{name}“ verschoben in {target}", + name = ink_notes::library::folder_name(&path), + target = target )); } window.request_redraw(); @@ -3379,11 +3402,11 @@ fn main() -> Result<(), Box> { .is_some() { let target = if folder.is_empty() { - "die oberste Ebene".to_string() + t!("die oberste Ebene").to_string() } else { format!("„{}“", ink_notes::library::folder_name(&folder)) }; - ui_state.set_export_message(format!("Verschoben in {target}")); + ui_state.set_export_message(t!("Verschoben in {target}", target = target)); } window.request_redraw(); } @@ -3409,7 +3432,7 @@ fn main() -> Result<(), Box> { eprintln!("[Storage-Lock] {msg}"); _notebook_lock = None; autosave.set_read_only(true); - ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)"); + ui_state.set_export_message(t!("⚠ In anderer Instanz geöffnet (Schreibschutz)")); } Err(e) => { eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); @@ -3429,13 +3452,13 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); if _notebook_lock.is_some() { - ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + ui_state.set_export_message(t!("Dokument '{title}' geöffnet", title = manifest.title)); } window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht laden: {err}"); - ui_state.set_export_message(format!("Fehler beim Öffnen: {err}")); + ui_state.set_export_message(t!("Fehler beim Öffnen: {err}", err = err)); } } } @@ -3470,15 +3493,15 @@ fn main() -> Result<(), Box> { canvas.reset_view(); canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!( - "'{}' importiert ({} Seiten)", - manifest.title, - notebook.pages.len() + ui_state.set_export_message(t!( + "'{title}' importiert ({count} Seiten)", + title = manifest.title, + count = notebook.pages.len() )); } Err(err) => { eprintln!("[PDF-Import] '{}': {err}", path.display()); - ui_state.set_export_message(format!("PDF nicht importiert: {err}")); + ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err)); } } window.request_redraw(); @@ -3504,12 +3527,12 @@ fn main() -> Result<(), Box> { ui_state.is_document_menu_open = false; canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Dokument '{}' erstellt", manifest.title)); + ui_state.set_export_message(t!("Dokument '{title}' erstellt", title = manifest.title)); window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}"); - ui_state.set_export_message(format!("Fehler beim Erstellen: {err}")); + ui_state.set_export_message(t!("Fehler beim Erstellen: {err}", err = err)); } } } @@ -3523,12 +3546,12 @@ fn main() -> Result<(), Box> { manifest.sync_from_notebook(¬ebook); autosave.mark_manifest_dirty(&manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); + ui_state.set_export_message(t!("Umbenannt in '{title}'", title = new_title)); window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht umbenennen: {err}"); - ui_state.set_export_message(format!("Fehler beim Umbenennen: {err}")); + ui_state.set_export_message(t!("Fehler beim Umbenennen: {err}", err = err)); } } } else { @@ -3537,7 +3560,7 @@ fn main() -> Result<(), Box> { if let Ok(mut other_manifest) = serde_json::from_str::(&content) { let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title); refresh_notebooks(&storage_base_dir, &mut ui_state); - ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); + ui_state.set_export_message(t!("Umbenannt in '{title}'", title = new_title)); window.request_redraw(); } } @@ -3583,13 +3606,13 @@ fn main() -> Result<(), Box> { ui_state.document_title = manifest.title.clone(); canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title)); + ui_state.set_export_message(t!("Gewechselt zu '{title}'", title = manifest.title)); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id); } } - } else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") { + } else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, t!("Standard-Notizbuch")) { _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); autosave.set_read_only(_notebook_lock.is_none()); autosave.switch_notebook_dir(new_dir.clone()); @@ -3602,16 +3625,16 @@ fn main() -> Result<(), Box> { refresh_notebooks(&storage_base_dir, &mut ui_state); canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message("Standard-Notizbuch erstellt".to_string()); + ui_state.set_export_message(t!("Standard-Notizbuch erstellt").to_string()); } } else { - ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string()); + ui_state.set_export_message(t!("Dokument in Papierkorb verschoben").to_string()); } window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht löschen: {err}"); - ui_state.set_export_message(format!("Fehler beim Löschen: {err}")); + ui_state.set_export_message(t!("Fehler beim Löschen: {err}", err = err)); } } } @@ -3620,12 +3643,12 @@ fn main() -> Result<(), Box> { match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) { Ok(_restored_dir) => { refresh_notebooks(&storage_base_dir, &mut ui_state); - ui_state.set_export_message("Notizbuch wiederhergestellt".to_string()); + ui_state.set_export_message(t!("Notizbuch wiederhergestellt").to_string()); window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}"); - ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}")); + ui_state.set_export_message(t!("Fehler beim Wiederherstellen: {err}", err = err)); } } } @@ -3635,18 +3658,18 @@ fn main() -> Result<(), Box> { match storage::restore_manifest_from_backup(&target_dir) { Ok(()) => { refresh_notebooks(&storage_base_dir, &mut ui_state); - ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string()); + ui_state.set_export_message(t!("Manifest aus Backup wiederhergestellt").to_string()); window.request_redraw(); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}"); - ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}")); + ui_state.set_export_message(t!("Wiederherstellung fehlgeschlagen: {err}", err = err)); } } } UiAction::OpenNotebookFolder(id) => { let target_dir = storage_base_dir.join(&id); - ui_state.set_export_message(format!("Ordner: {}", target_dir.display())); + ui_state.set_export_message(t!("Ordner: {path}", path = target_dir.display())); window.request_redraw(); } UiAction::CheckForUpdates => { @@ -3659,9 +3682,9 @@ fn main() -> Result<(), Box> { } UiAction::InstallOrOpenUpdate(path) => { if let Err(err) = updater.install_or_open(&path) { - ui_state.set_export_message(format!("Update-Fehler: {err}")); + ui_state.set_export_message(t!("Update-Fehler: {err}", err = err)); } else { - ui_state.set_export_message("Update gestartet / geöffnet"); + ui_state.set_export_message(t!("Update gestartet / geöffnet")); } window.request_redraw(); } @@ -3671,7 +3694,7 @@ fn main() -> Result<(), Box> { UiAction::SaveUpdaterConfig { server_url, auth_token } => { updater.set_server_url(server_url); updater.set_auth_token(auth_token); - ui_state.set_export_message("Server-Einstellungen gespeichert"); + ui_state.set_export_message(t!("Server-Einstellungen gespeichert")); window.request_redraw(); } UiAction::UnlockCorruptedPage(page_id) => { @@ -3709,7 +3732,7 @@ fn main() -> Result<(), Box> { Err(err) => { eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}"); if let Some(ref mut se) = ui_state.shutdown_error { - se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}")); + se.messages.push(t!("Notfall-Export fehlgeschlagen: {err}", err = err)); } window.request_redraw(); } @@ -3720,6 +3743,13 @@ fn main() -> Result<(), Box> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SetLanguage(language) => { + ui_settings.language = language; + i18n::set_language(language.unwrap_or_else(Language::system)); + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } UiAction::SetPaperTheme { theme, adapt_ink } => { canvas.set_paper_theme(theme, adapt_ink); canvas.sync_page(notebook.current_page_mut()); diff --git a/src/page_export.rs b/src/page_export.rs index 396d762..073b0d9 100644 --- a/src/page_export.rs +++ b/src/page_export.rs @@ -15,6 +15,7 @@ use crate::assets::{AssetStore, RgbaImage}; use crate::background::PatternColor; use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH}; use crate::export::{export_notebook_to_bytes, ExportStrategy}; +use crate::t; /// Auflösung des PNG-Exports einer Seite. pub const PAGE_PNG_DPI: f32 = 200.0; @@ -50,8 +51,8 @@ fn content_only(page: &Page) -> Page { /// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation. fn rasterize(pdf_bytes: Vec, dpi: f32, transparent: bool) -> Result> { - let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?; - let page = pdf.pages().first().ok_or("PDF ohne Seite")?; + let pdf = Pdf::new(pdf_bytes).map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?; + let page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?; let scale = dpi / 72.0; let pixmap = hayro::render( page, @@ -84,7 +85,7 @@ fn rasterize(pdf_bytes: Vec, dpi: f32, transparent: bool) -> Result Result, Box> { let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone()) - .ok_or("Pixeldaten passen nicht zur Bildgröße")?; + .ok_or(t!("Pixeldaten passen nicht zur Bildgröße"))?; let mut out = Vec::new(); image::DynamicImage::ImageRgba8(buffer) .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?; @@ -118,8 +119,8 @@ pub fn page_svg( pattern: PatternColor, ) -> Result> { let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?) - .map_err(|e| format!("PDF nicht lesbar: {e:?}"))?; - let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?; + .map_err(|e| t!("PDF nicht lesbar: {e}", e = format!("{e:?}")))?; + let pdf_page = pdf.pages().first().ok_or(t!("PDF ohne Seite"))?; Ok(hayro_svg::convert( pdf_page, &hayro_svg::RenderCache::new(), diff --git a/src/pdf_doc.rs b/src/pdf_doc.rs index 5c62f8f..a1a11b0 100644 --- a/src/pdf_doc.rs +++ b/src/pdf_doc.rs @@ -13,6 +13,7 @@ use hayro::vello_cpu::color::palette::css::WHITE; use hayro::{PixmapSettings, RenderCache, RenderSettings}; use crate::assets::RgbaImage; +use crate::t; /// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi). pub const DISPLAY_RENDER_PIXELS: u32 = 2560; @@ -38,12 +39,13 @@ pub fn page_sizes(bytes: Vec) -> Result, String> { let pdf = open(bytes)?; let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect(); if sizes.is_empty() { - return Err("Das PDF enthält keine Seiten".into()); + return Err(t!("Das PDF enthält keine Seiten").into()); } if sizes.len() > MAX_IMPORT_PAGES { - return Err(format!( - "Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt", - sizes.len() + return Err(t!( + "Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", + count = sizes.len(), + max = MAX_IMPORT_PAGES )); } Ok(sizes) @@ -52,10 +54,10 @@ pub fn page_sizes(bytes: Vec) -> Result, String> { fn open(bytes: Vec) -> Result { Pdf::new(bytes).map_err(|e| match e { LoadPdfError::Decryption(_) => { - "Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden" + t!("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden") .to_string() } - LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(), + LoadPdfError::Invalid => t!("Die Datei ist kein lesbares PDF").to_string(), }) } @@ -65,7 +67,7 @@ pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result render_page(pdf, request.page, request.max_pixels), - None => Err("PDF kann nicht geöffnet werden".into()), + None => Err(t!("PDF kann nicht geöffnet werden").into()), }; if res_tx.send((request.key, result)).is_err() { break; @@ -206,7 +208,7 @@ impl PdfRenderService { return Some(result); } if !self.request(key, path, page, max_pixels) { - return Some(Err("PDF-Render-Thread beendet".into())); + return Some(Err(t!("PDF-Render-Thread beendet").into())); } None } diff --git a/src/pdf_merge.rs b/src/pdf_merge.rs index eaf3e1a..1efb511 100644 --- a/src/pdf_merge.rs +++ b/src/pdf_merge.rs @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, HashMap}; use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream}; +use crate::t; + /// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird. #[derive(Debug, Clone, PartialEq)] pub struct Underlay { @@ -137,7 +139,7 @@ fn page_as_form( .map_err(|e| e.to_string())?; let data = stream .decompressed_content() - .map_err(|e| format!("Inhalt nicht lesbar: {e}"))?; + .map_err(|e| t!("Inhalt nicht lesbar: {e}", e = e))?; content.extend_from_slice(&data); content.push(b'\n'); } @@ -183,10 +185,10 @@ pub fn apply_underlays( let result = (|| { let bytes = sources .get(&underlay.source) - .ok_or_else(|| "Quell-PDF fehlt".to_string())?; + .ok_or_else(|| t!("Quell-PDF fehlt").to_string())?; let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?; if source.is_encrypted() { - return Err("Quell-PDF ist verschlüsselt".to_string()); + return Err(t!("Quell-PDF ist verschlüsselt").to_string()); } source.renumber_objects_with(target.max_id + 1); let pages = source.get_pages(); @@ -206,10 +208,15 @@ pub fn apply_underlays( .map_err(Clone::clone)?; let source_page = *pages .get(&(underlay.source_page as u32 + 1)) - .ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?; + .ok_or_else(|| { + t!( + "Seite {number} fehlt im Quell-PDF", + number = underlay.source_page + 1 + ) + })?; let target_page = *target_pages .get(underlay.target_page) - .ok_or_else(|| "Zielseite fehlt".to_string())?; + .ok_or_else(|| t!("Zielseite fehlt").to_string())?; let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?; let form_id = target.add_object(Object::Stream(form)); let name = format!("InkPdf{}", form_id.0); diff --git a/src/selection.rs b/src/selection.rs index 3c5e589..c4fa1d0 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -7,6 +7,7 @@ use std::f32::consts::FRAC_PI_2; use crate::canvas::CanvasState; use crate::document::{Aabb, Page, Stroke}; +use crate::t; /// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus. #[derive(Debug, Clone, PartialEq, Default)] @@ -404,15 +405,14 @@ pub fn render_selection_overlay( } // Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen") - let badge_text = format!( - "{} {} | [R] Drehen [Entf] Löschen", - selection.count(), - if selection.count() == 1 { - "Objekt" - } else { - "Objekte" - } - ); + let badge_text = if selection.count() == 1 { + t!("1 Objekt | [R] Drehen [Entf] Löschen").to_string() + } else { + t!( + "{count} Objekte | [R] Drehen [Entf] Löschen", + count = selection.count() + ) + }; let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0); painter.text( badge_pos, diff --git a/src/settings.rs b/src/settings.rs index 4d7cb9b..42d0a61 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::background::{PaperTheme, PatternColor}; +use crate::i18n::Language; use crate::storage::{self, StorageError}; +use crate::t; /// Fensterseite, an der die Werkzeugleiste angedockt ist. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -177,11 +179,11 @@ impl StylusAction { pub fn label(self) -> &'static str { match self { - StylusAction::Erase => "Radieren", - StylusAction::Select => "Auswählen", - StylusAction::Highlighter => "Textmarker", - StylusAction::Pen => "Stift", - StylusAction::Nothing => "Nichts", + StylusAction::Erase => t!("Radieren"), + StylusAction::Select => t!("Auswählen"), + StylusAction::Highlighter => t!("Textmarker"), + StylusAction::Pen => t!("Stift"), + StylusAction::Nothing => t!("Nichts"), } } } @@ -222,6 +224,8 @@ pub struct UiSettings { pub active_text_color: usize, /// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden pub stylus_buttons: Vec, + /// Sprache der Oberfläche; ohne Angabe die des Systems + pub language: Option, } impl Default for UiSettings { @@ -240,6 +244,7 @@ impl Default for UiSettings { text_colors: DEFAULT_TEXT_COLORS, active_text_color: 0, stylus_buttons: Vec::new(), + language: None, } } } @@ -338,6 +343,7 @@ mod tests { action: StylusAction::Nothing, }, ], + language: Some(Language::English), }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); diff --git a/src/storage.rs b/src/storage.rs index fc45f77..4e8deec 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -15,6 +15,7 @@ use crate::document::{ }; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; +use crate::t; /// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -371,19 +372,30 @@ pub enum StorageError { impl fmt::Display for StorageError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"), - StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"), - StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"), - StorageError::CorruptedPage(id, msg) => { - write!(f, "Beschädigte Seite {id}: {msg}") + StorageError::Io(e) => f.write_str(&t!("I/O-Fehler: {e}", e = e)), + StorageError::Serialization(e) => { + f.write_str(&t!("Serialisierungsfehler: {e}", e = e)) + } + StorageError::InvalidManifest(msg) => { + f.write_str(&t!("Ungültiges Manifest: {msg}", msg = msg)) + } + StorageError::CorruptedPage(id, msg) => { + f.write_str(&t!("Beschädigte Seite {id}: {msg}", id = id, msg = msg)) + } + StorageError::PageNotFound(id) => { + f.write_str(&t!("Seite {id} nicht gefunden", id = id)) + } + StorageError::UnsupportedVersion { found, supported } => f.write_str(&t!( + "Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})", + found = found, + supported = supported + )), + StorageError::AlreadyLocked(msg) => { + f.write_str(&t!("Notizbuch ist gesperrt: {msg}", msg = msg)) + } + StorageError::PathTraversal(msg) => { + f.write_str(&t!("Ungültiger Pfad (Path Traversal): {msg}", msg = msg)) } - StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"), - StorageError::UnsupportedVersion { found, supported } => write!( - f, - "Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})" - ), - StorageError::AlreadyLocked(msg) => write!(f, "Notizbuch ist gesperrt: {msg}"), - StorageError::PathTraversal(msg) => write!(f, "Ungültiger Pfad (Path Traversal): {msg}"), } } } @@ -490,7 +502,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let dir = path.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, - "Übergeordnetes Verzeichnis nicht gefunden", + t!("Übergeordnetes Verzeichnis nicht gefunden"), ) })?; create_secure_dir_all(dir)?; @@ -595,9 +607,9 @@ pub fn load_manifest(notebook_dir: &Path) -> Result Result Result<(Notebook, NotebookManifest) first_id, PageLoadWarning { page_id: first_id, - message: format!("I/O-Fehler: {e}"), + message: t!("I/O-Fehler: {e}", e = e), quarantine_path: None, }, ); @@ -876,7 +888,7 @@ pub fn ensure_page_loaded( page_id, PageLoadWarning { page_id, - message: format!("I/O-Fehler: {err_str}"), + message: t!("I/O-Fehler: {e}", e = err_str), quarantine_path: None, }, ); @@ -970,9 +982,9 @@ impl NotebookLock { Err(e) => { let kind = e.kind(); if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) { - Err(StorageError::AlreadyLocked(format!( - "Notizbuch '{}' wird bereits von einer anderen Instanz verwendet", - notebook_dir.display() + Err(StorageError::AlreadyLocked(t!( + "Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet", + path = notebook_dir.display() ))) } else { Err(StorageError::Io(e)) @@ -1058,7 +1070,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { list.push(NotebookSummary { id: dir_name.to_string(), - title: format!("⚠️ {dir_name} (Beschädigt)"), + title: t!("⚠️ {name} (Beschädigt)", name = dir_name), page_count: 0, modified_at, status: NotebookStatus::Damaged, @@ -1077,11 +1089,11 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { list.push(NotebookSummary { id: dir_name.to_string(), - title: format!("⚠️ {dir_name} (Beschädigt - kein Manifest)"), + title: t!("⚠️ {name} (Beschädigt - kein Manifest)", name = dir_name), page_count: 0, modified_at, status: NotebookStatus::Damaged, - error: Some("Keine manifest.json vorhanden".to_string()), + error: Some(t!("Keine manifest.json vorhanden").to_string()), }); } } @@ -1135,7 +1147,7 @@ pub fn create_new_notebook( if counter > 100 { return Err(StorageError::Io(io::Error::new( io::ErrorKind::AlreadyExists, - "Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", + t!("Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden"), ))); } } @@ -1214,18 +1226,18 @@ pub fn rename_notebook( /// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält. pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result { if !notebook_dir.exists() { - return Err(StorageError::InvalidManifest(format!( - "Verzeichnis '{}' existiert nicht", - notebook_dir.display() + return Err(StorageError::InvalidManifest(t!( + "Verzeichnis '{path}' existiert nicht", + path = notebook_dir.display() ))); } // Prüfen auf Symlink let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?; if symlink_meta.file_type().is_symlink() { - return Err(StorageError::InvalidManifest(format!( - "Pfad '{}' ist ein Symlink und darf nicht gelöscht werden", - notebook_dir.display() + return Err(StorageError::InvalidManifest(t!( + "Pfad '{path}' ist ein Symlink und darf nicht gelöscht werden", + path = notebook_dir.display() ))); } @@ -1234,18 +1246,18 @@ pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result Result<(), StorageEr Ok(()) } else { Err(StorageError::InvalidManifest( - "Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(), + t!("Keine Sicherungskopie (manifest.json.bak) vorhanden").to_string(), )) } } @@ -1387,7 +1399,7 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<() if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { return Err(StorageError::CorruptedPage( page.id, - "Seite konnte nicht geladen werden".to_string(), + t!("Seite konnte nicht geladen werden").to_string(), )); } if !page.is_loaded { diff --git a/src/text.rs b/src/text.rs index cf65b0a..656d72d 100644 --- a/src/text.rs +++ b/src/text.rs @@ -10,6 +10,8 @@ use std::sync::OnceLock; +use crate::t; + use ab_glyph::{Font, FontRef, GlyphId, PxScale}; use serde::{Deserialize, Serialize}; @@ -29,8 +31,8 @@ pub enum TextFont { impl TextFont { pub fn label(self) -> &'static str { match self { - TextFont::Sans => "Normal", - TextFont::Mono => "Code", + TextFont::Sans => t!("Normal"), + TextFont::Mono => t!("Code"), } } diff --git a/src/ui.rs b/src/ui.rs index 2c21026..f022d43 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -24,6 +24,7 @@ use crate::settings::{ }; use crate::storage::NotebookSummary; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; +use crate::{t, tn}; /// Aktives Werkzeug für das Zeichnen oder Bearbeiten. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,13 +43,13 @@ pub enum ActiveTool { 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)", - ActiveTool::Text => "Text (T)", - ActiveTool::Space => "Platz schaffen", + ActiveTool::Pen => t!("Stift (Pen)"), + ActiveTool::Highlighter => t!("Textmarker (Highlighter)"), + ActiveTool::Eraser => t!("Radierer (Eraser)"), + ActiveTool::Shape => t!("Formen (Shape)"), + ActiveTool::Select => t!("Auswahl / Lasso (Select)"), + ActiveTool::Text => t!("Text (T)"), + ActiveTool::Space => t!("Platz schaffen"), } } @@ -76,20 +77,20 @@ pub type ColorPreset = ([f32; 4], &'static str); /// - 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"), + ([0.12, 0.12, 0.14, 1.0], tn!("Notiz-Schwarz")), + ([0.15, 0.35, 0.85, 1.0], tn!("Universitäts-Blau")), + ([0.85, 0.20, 0.20, 1.0], tn!("Korrektur-Rot")), + ([0.18, 0.65, 0.35, 1.0], tn!("Tafel-Grün")), + ([0.45, 0.45, 0.48, 1.0], tn!("Graphit-Grau")), ]; /// Schnellauswahlfarben für den Textmarker (halbtransparent, Alpha 0.35): /// - Neon-Gelb, Hellgrün, Pastell-Cyan, Rosa pub const HIGHLIGHTER_COLOR_PRESETS: [([f32; 4], &str); 4] = [ - ([1.0, 0.95, 0.1, 0.35], "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"), + ([1.0, 0.95, 0.1, 0.35], tn!("Neon-Gelb")), + ([0.2, 0.9, 0.3, 0.35], tn!("Hellgrün")), + ([0.25, 0.7, 1.0, 0.35], tn!("Pastell-Cyan")), + ([1.0, 0.3, 0.65, 0.35], tn!("Rosa")), ]; /// Dicken-Presets für feines Inking und präzise Notizen: @@ -303,6 +304,8 @@ pub enum UiAction { UnlockCorruptedPage(u64), /// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert) SetSidebarSide(SidebarSide), + /// Sprache der Oberfläche; `None` = Sprache des Systems + SetLanguage(Option), /// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert) SetPaperTheme { theme: PaperTheme, @@ -411,6 +414,8 @@ pub struct UiState { pub shutdown_error: Option, /// Fensterseite der Werkzeugleiste pub sidebar_side: SidebarSide, + /// Gewählte Sprache der Oberfläche; `None` = Sprache des Systems + pub language: Option, /// Öffnungszustand des ☰-Menüs pub is_more_menu_open: bool, /// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste) @@ -471,7 +476,7 @@ impl Default for UiState { popover_rect: None, is_document_menu_open: false, current_notebook_id: "default".to_string(), - document_title: "Standard-Notizbuch".to_string(), + document_title: t!("Standard-Notizbuch").to_string(), available_notebooks: Vec::new(), new_notebook_title_input: String::new(), renaming_notebook_id: None, @@ -505,6 +510,7 @@ impl Default for UiState { restorable_page: None, shutdown_error: None, sidebar_side: SidebarSide::Left, + language: None, is_more_menu_open: false, eraser_mode: EraserMode::All, paper_theme: PaperTheme::White, @@ -879,7 +885,7 @@ fn stroke_preview_button( ); } - response.on_hover_text(format!("Stärke auf {} setzen", label_text)) + response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text)) } /// Inhalt eines Symbol-Knopfs der Werkzeugleiste. @@ -1101,13 +1107,16 @@ fn popover_placement( /// 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::Saved => ( + egui::Color32::from_rgb(46, 204, 113), + t!("Alles gespeichert"), + ), + AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), t!("Speichert …")), AutosaveStatus::Pending => ( egui::Color32::from_rgb(230, 126, 34), - "Ungespeicherte Änderungen", + t!("Ungespeicherte Änderungen"), ), - AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), "Speicherfehler!"), + AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), t!("Speicherfehler!")), } } @@ -1129,7 +1138,7 @@ fn color_editor( .iter() .zip(&color[..3]) .all(|(a, b)| (a - b).abs() < 0.01); - if color_circle_swatch(ui, preset, is_sel, label, 30.0).clicked() { + if color_circle_swatch(ui, preset, is_sel, crate::i18n::tr(label), 30.0).clicked() { *color = [preset[0], preset[1], preset[2], color[3]]; changed = true; } @@ -1139,7 +1148,7 @@ fn color_editor( *picker_open, egui::RichText::new("🎨").size(18.0), )) - .on_hover_text("Eigene Farbe wählen") + .on_hover_text(t!("Eigene Farbe wählen")) .clicked() { *picker_open = !*picker_open; @@ -1379,8 +1388,8 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) { .copied() .find(|&s| s < size - 0.01); let larger = TEXT_SIZES_PT.iter().copied().find(|&s| s > size + 0.01); - let smaller_tip = format!("Kleinere Schrift (jetzt {size} pt)"); - let larger_tip = format!("Größere Schrift (jetzt {size} pt)"); + let smaller_tip = t!("Kleinere Schrift (jetzt {size} pt)", size = size); + let larger_tip = t!("Größere Schrift (jetzt {size} pt)", size = size); enum Change { Font(TextFont), /// Neue Größe; `None`: keine kleinere bzw. größere mehr @@ -1389,12 +1398,12 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) { let buttons = [ ( SidebarIcon::Text("Aa"), - "Normale Schrift", + t!("Normale Schrift"), Change::Font(TextFont::Sans), ), ( SidebarIcon::Text("{ }"), - "Code-Schrift (feste Zeichenbreite, Tab rückt ein)", + t!("Code-Schrift (feste Zeichenbreite, Tab rückt ein)"), Change::Font(TextFont::Mono), ), (SidebarIcon::Text("A−"), &smaller_tip, Change::Size(smaller)), @@ -1412,7 +1421,7 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) { Change::Font(font) => state.text_font = font, Change::Size(Some(new_size)) => { state.text_size_pt = new_size; - state.set_export_message(format!("Schriftgröße {new_size} pt")); + state.set_export_message(t!("Schriftgröße {size} pt", size = new_size)); } Change::Size(None) => {} } @@ -1558,8 +1567,12 @@ fn page_thumbnail( ); } match &page.title { - Some(title) => response.on_hover_text(format!("Seite {number} – {title}")), - None => response.on_hover_text(format!("Seite {number}")), + Some(title) => response.on_hover_text(t!( + "Seite {number} – {title}", + number = number, + title = title + )), + None => response.on_hover_text(t!("Seite {number}", number = number)), } } @@ -1570,7 +1583,7 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option { if notebook.pages.iter().all(|p| p.title.is_none()) { ui.label( egui::RichText::new( - "Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein.", + t!("Noch keine Seite hat einen Titel. Gib oben einen Titel für die aktuelle Seite ein."), ) .color(egui::Color32::from_gray(150)), ); @@ -1659,7 +1672,7 @@ fn pattern_color_button( egui::Rounding::same(3.0), egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)), ); - response.on_hover_text("Gilt für alle Seiten, auch beim Export") + response.on_hover_text(t!("Gilt für alle Seiten, auch beim Export")) } /// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort @@ -1671,13 +1684,13 @@ fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool { use crate::settings::StylusAction; let mut changed = false; ui.add_space(4.0); - ui.label(egui::RichText::new("Tasten am Stift").color(egui::Color32::from_gray(200))); + ui.label(egui::RichText::new(t!("Tasten am Stift")).color(egui::Color32::from_gray(200))); if state.stylus_buttons.is_empty() { ui.label( - egui::RichText::new( + egui::RichText::new(t!( "Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \ - Bis dahin radieren alle Tasten.", - ) + Bis dahin radieren alle Tasten." + )) .size(11.0) .color(egui::Color32::from_gray(150)), ); @@ -1687,13 +1700,13 @@ fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool { for i in 0..state.stylus_buttons.len() { ui.horizontal(|ui| { let pressed = state.pressed_stylus_button == Some(i); - let label = egui::RichText::new(format!("Taste {}", i + 1)); + let label = egui::RichText::new(t!("Taste {number}", number = i + 1)); ui.label(if pressed { label.strong().color(egui::Color32::from_rgb(46, 204, 113)) } else { label }) - .on_hover_text("Leuchtet grün, solange die Taste gedrückt ist"); + .on_hover_text(t!("Leuchtet grün, solange die Taste gedrückt ist")); let current = state.stylus_buttons[i].action; egui::ComboBox::from_id_source(("stylus_button", i)) .selected_text(current.label()) @@ -1712,9 +1725,9 @@ fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool { }); if ui .small_button("🗙") - .on_hover_text( - "Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)", - ) + .on_hover_text(t!( + "Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)" + )) .clicked() { remove = Some(i); @@ -1738,7 +1751,7 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option Option().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.")); + ).on_hover_text(t!("Leichterer Druck zählt als kein Druck (dünnste Linie).\nHilft bei Stiften, die schon beim Schweben Druck melden."))); }); if ui .add_enabled( state.pressure_curve != PressureCurve::default(), - egui::Button::new("Druckkurve zurücksetzen"), + egui::Button::new(t!("Druckkurve zurücksetzen")), ) .clicked() { @@ -1816,7 +1829,10 @@ fn folder_breadcrumbs(ui: &mut egui::Ui, state: &mut UiState, folder: &str) { ui.spacing_mut().item_spacing.x = 4.0; let mut target = None; if ui - .selectable_label(folder.is_empty(), egui::RichText::new("📚 Alle").size(13.0)) + .selectable_label( + folder.is_empty(), + egui::RichText::new(t!("📚 Alle")).size(13.0), + ) .clicked() { target = Some(String::new()); @@ -1876,23 +1892,25 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut .show(ui, |ui| { ui.vertical(|ui| { ui.add( - egui::Label::new(format!( + egui::Label::new(t!( "Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine \ - Ebene nach oben, gelöscht wird kein Notizbuch." + Ebene nach oben, gelöscht wird kein Notizbuch.", + name = name )) .wrap(true), ); ui.horizontal(|ui| { if ui .button( - egui::RichText::new("Ordner löschen").color(egui::Color32::WHITE), + egui::RichText::new(t!("Ordner löschen")) + .color(egui::Color32::WHITE), ) .clicked() { actions.push(UiAction::DeleteFolder(path.to_string())); state.confirm_delete_folder = None; } - if ui.button("Abbrechen").clicked() { + if ui.button(t!("Abbrechen")).clicked() { state.confirm_delete_folder = None; } }); @@ -1921,21 +1939,21 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut .size(16.0) .color(egui::Color32::from_rgb(255, 120, 120)), ) - .on_hover_text("Ordner löschen (die Notizbücher bleiben erhalten)") + .on_hover_text(t!("Ordner löschen (die Notizbücher bleiben erhalten)")) .clicked() { state.confirm_delete_folder = Some(path.to_string()); } if ui .button(egui::RichText::new("📂").size(16.0)) - .on_hover_text("Ordner samt Inhalt in einen anderen Ordner verschieben") + .on_hover_text(t!("Ordner samt Inhalt in einen anderen Ordner verschieben")) .clicked() { state.moving = Some(MoveSubject::Folder(path.to_string())); } if ui .button(egui::RichText::new("✏").size(16.0)) - .on_hover_text("Ordner umbenennen") + .on_hover_text(t!("Ordner umbenennen")) .clicked() { state.renaming_folder = Some(path.to_string()); @@ -1953,8 +1971,8 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut ); ui.label( egui::RichText::new(match count { - 1 => "1 Notizbuch".to_string(), - n => format!("{n} Notizbücher"), + 1 => t!("1 Notizbuch").to_string(), + n => t!("{n} Notizbücher", n = n), }) .size(11.0) .color(egui::Color32::from_gray(160)), @@ -1999,9 +2017,9 @@ fn move_target_list( state.library.folder_move_targets(path), ), }; - ui.label(egui::RichText::new(format!("„{title}“ verschieben nach:")).strong()); + ui.label(egui::RichText::new(t!("„{title}“ verschieben nach:", title = title)).strong()); ui.add_space(4.0); - let mut targets = vec![(String::new(), "📚 Oberste Ebene".to_string(), 0)]; + let mut targets = vec![(String::new(), t!("📚 Oberste Ebene").to_string(), 0)]; for path in folders { let depth = path.matches('/').count() + 1; targets.push(( @@ -2019,7 +2037,7 @@ fn move_target_list( let here = path == current; if ui .add_enabled(!here, egui::Button::new(label)) - .on_disabled_hover_text("Liegt bereits hier") + .on_disabled_hover_text(t!("Liegt bereits hier")) .clicked() { actions.push(match subject { @@ -2038,7 +2056,7 @@ fn move_target_list( } }); ui.add_space(6.0); - if ui.button("Abbrechen").clicked() { + if ui.button(t!("Abbrechen")).clicked() { state.moving = None; } } @@ -2053,16 +2071,21 @@ fn delete_page_buttons( let number = notebook.current_page_index + 1; if state.confirm_delete_page { ui.label( - egui::RichText::new(format!("Seite {number} mit allem Inhalt löschen?")) - .color(egui::Color32::from_rgb(255, 170, 160)), + egui::RichText::new(t!( + "Seite {number} mit allem Inhalt löschen?", + number = number + )) + .color(egui::Color32::from_rgb(255, 170, 160)), ); ui.horizontal(|ui| { let half = (ui.available_width() - ui.spacing().item_spacing.x) / 2.0; if ui .add_sized( [half, 30.0], - egui::Button::new(egui::RichText::new("Löschen").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(170, 50, 45)), + egui::Button::new( + egui::RichText::new(t!("Löschen")).color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(170, 50, 45)), ) .clicked() { @@ -2070,7 +2093,7 @@ fn delete_page_buttons( state.confirm_delete_page = false; } if ui - .add_sized([half, 30.0], egui::Button::new("Abbrechen")) + .add_sized([half, 30.0], egui::Button::new(t!("Abbrechen"))) .clicked() { state.confirm_delete_page = false; @@ -2082,14 +2105,16 @@ fn delete_page_buttons( .add_enabled_ui(!only_page, |ui| { ui.add_sized( [ui.available_width(), 30.0], - egui::Button::new(format!("🗑 Seite {number} löschen …")), + egui::Button::new(t!("🗑 Seite {number} löschen …", number = number)), ) }) .inner - .on_hover_text("Löscht die aktuelle Seite; danach im Menü wiederherstellbar") - .on_disabled_hover_text( - "Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", - ); + .on_hover_text(t!( + "Löscht die aktuelle Seite; danach im Menü wiederherstellbar" + )) + .on_disabled_hover_text(t!( + "Die einzige Seite eines Notizbuchs kann nicht gelöscht werden" + )); if response.clicked() { state.confirm_delete_page = true; } @@ -2098,7 +2123,10 @@ fn delete_page_buttons( if ui .add_sized( [ui.available_width(), 30.0], - egui::Button::new(format!("⟲ Gelöschte Seite {number} wiederherstellen")), + egui::Button::new(t!( + "⟲ Gelöschte Seite {number} wiederherstellen", + number = number + )), ) .clicked() { @@ -2122,17 +2150,17 @@ fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec= 0.0 { - format!("⏷ {mm:.0} mm Platz") + t!("⏷ {mm} mm Platz", mm = format!("{mm:.0}")) } else { format!("⏶ {:.0} mm", -mm) }, @@ -2382,19 +2410,21 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve let picker_kind = picker.kind; let (title, header, file_icon, empty_hint, tip) = match picker.kind { PickerKind::Image => ( - "Bild einfügen", - "🖼 Bild einfügen", + t!("Bild einfügen"), + t!("🖼 Bild einfügen"), "🖼", - "Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", - "Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.", + t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"), + t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."), ), PickerKind::Pdf => ( - "PDF importieren", - "📄 PDF importieren", + t!("PDF importieren"), + t!("📄 PDF importieren"), "📄", - "Keine Ordner oder PDF-Dateien", - "Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \ - Beim Export liegen deine Notizen über den Originalseiten.", + t!("Keine Ordner oder PDF-Dateien"), + t!( + "Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \ + Beim Export liegen deine Notizen über den Originalseiten." + ), ), }; dialog_window(title, state, false).show(ctx, |ui| { @@ -2406,7 +2436,10 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve ui.horizontal_wrapped(|ui| { for (label, path) in crate::file_picker::shortcuts() { - if ui.selectable_label(picker.dir == path, label).clicked() { + if ui + .selectable_label(picker.dir == path, crate::i18n::tr(label)) + .clicked() + { navigate = Some(path); } } @@ -2415,7 +2448,7 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve ui.horizontal(|ui| { if ui .add_enabled(picker.dir.parent().is_some(), egui::Button::new("⏶")) - .on_hover_text("Übergeordneter Ordner") + .on_hover_text(t!("Übergeordneter Ordner")) .clicked() { go_up = true; @@ -2582,7 +2615,7 @@ fn close_button(ui: &mut egui::Ui) -> egui::Response { [36.0, 36.0], egui::Button::new(egui::RichText::new("🗙").size(16.0)).frame(false), ) - .on_hover_text("Schließen (Esc)") + .on_hover_text(t!("Schließen (Esc)")) } /// Größere Knöpfe, Eingabefelder und Schieberegler für Finger und Stift. @@ -2688,10 +2721,10 @@ pub fn render_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" + let doc_tooltip = t!( + "Notizbücher verwalten (aktuell: {title})", + title = if state.document_title.is_empty() { + t!("Notizbuch") } else { state.document_title.as_str() } @@ -2736,7 +2769,7 @@ pub fn render_ui( if state.is_pen_menu_open { "" } else { - "Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke" + t!("Stift (B/U/R für Farben) – erneut tippen für Farbe & Stärke") }, ); pen_btn_rect = Some(pen_resp.rect); @@ -2768,7 +2801,7 @@ pub fn render_ui( if state.is_highlighter_menu_open { "" } else { - "Textmarker – erneut tippen für Farbe & Stärke" + t!("Textmarker – erneut tippen für Farbe & Stärke") }, ); high_btn_rect = Some(high_resp.rect); @@ -2794,30 +2827,30 @@ pub fn render_ui( ( ActiveTool::Shape, SidebarIcon::Glyph("📐"), - "Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", + t!("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)"), ), ( ActiveTool::Eraser, SidebarIcon::Eraser, - "Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", + t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"), ), ( ActiveTool::Select, SidebarIcon::Lasso, - "Auswählen: verschieben, drehen, skalieren, kopieren (V)", + t!("Auswählen: verschieben, drehen, skalieren, kopieren (V)"), ), ( ActiveTool::Space, SidebarIcon::Glyph("↕"), - "Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \ + t!("Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \ darunter rutscht mit, auch auf die folgenden Seiten; nach oben \ - schließt leeren Platz", + schließt leeren Platz"), ), ( ActiveTool::Text, SidebarIcon::Glyph("T"), - "Text (T): ins Blatt tippen und schreiben, vorhandenen Text \ - antippen zum Ändern; Esc beendet", + t!("Text (T): ins Blatt tippen und schreiben, vorhandenen Text \ + antippen zum Ändern; Esc beendet"), ), ] { if sidebar_button( @@ -2847,8 +2880,8 @@ pub fn render_ui( state.ruler.is_some(), true, None, - "Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \ - Striche an einer Kante werden gerade", + t!("Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \ + Striche an einer Kante werden gerade"), ) .clicked() { @@ -2868,14 +2901,14 @@ pub fn render_ui( let preset = state.pen_presets[i]; let selected = i == state.active_pen; let tooltip = if selected { - format!( - "Stift {} ({:.2} mm) – erneut tippen für Farbe & Stärke; \ + t!( + "Stift {number} ({width} mm) – erneut tippen für Farbe & Stärke; \ Änderungen merkt sich der Stift", - i + 1, - preset.width_mm + number = i + 1, + width = format!("{:.2}", preset.width_mm) ) } else { - format!("Stift {} ({:.2} mm)", i + 1, preset.width_mm) + t!("Stift {number} ({width} mm)", number = i + 1, width = format!("{:.2}", preset.width_mm)) }; let response = pen_preset_swatch(ui, preset, selected, &tooltip, swatch); @@ -2926,13 +2959,13 @@ pub fn render_ui( }; let tooltip = match tool { ActiveTool::Select => { - format!("Auswahl in der Farbe von Stift {} umfärben", i + 1) + t!("Auswahl in der Farbe von Stift {number} umfärben", number = i + 1) } - _ if selected => format!( - "Farbe {} – erneut tippen, um sie anzupassen", - i + 1 + _ if selected => t!( + "Farbe {number} – erneut tippen, um sie anzupassen", + number = i + 1 ), - _ => format!("Farbe {}", i + 1), + _ => t!("Farbe {number}", number = i + 1), }; let response = color_circle_swatch(ui, color, selected, &tooltip, swatch); @@ -2979,21 +3012,21 @@ pub fn render_ui( for (mode, icon, indicator, tooltip) in [ ( EraserMode::All, - SidebarIcon::Text("Alle"), + SidebarIcon::Text(t!("Alle")), None, - "Alle Striche radieren", + t!("Alle Striche radieren"), ), ( EraserMode::Partial, SidebarIcon::Glyph("✂"), None, - "Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)", + t!("Teilstrich radieren – nur den überstrichenen Teil entfernen (Formen ganz)"), ), ( EraserMode::HighlighterOnly, SidebarIcon::Glyph("🖊"), Some(state.highlighter_color), - "Nur Textmarker radieren – Handschrift darunter bleibt", + t!("Nur Textmarker radieren – Handschrift darunter bleibt"), ), ] { if sidebar_button( @@ -3022,10 +3055,10 @@ pub fn render_ui( 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), + ("📋", t!("Kopieren (Strg+C)"), UiAction::CopySelected), + ("📥", t!("Einfügen (Strg+V)"), UiAction::PasteSelected), + ("🔄", t!("Um 90° drehen (R)"), UiAction::RotateSelected), + ("🗑", t!("Löschen (Entf)"), UiAction::DeleteSelected), ] { if sidebar_button( ui, @@ -3060,7 +3093,7 @@ pub fn render_ui( false, history.can_undo(), None, - "Rückgängig (Strg+Z)", + t!("Rückgängig (Strg+Z)"), ) .clicked() { @@ -3073,7 +3106,7 @@ pub fn render_ui( false, history.can_redo(), None, - "Wiederholen (Strg+Y)", + t!("Wiederholen (Strg+Y)"), ) .clicked() { @@ -3094,7 +3127,7 @@ pub fn render_ui( false, notebook.current_page_index > 0, None, - "Vorherige Seite (Bild auf)", + t!("Vorherige Seite (Bild auf)"), ) .clicked() { @@ -3112,7 +3145,7 @@ pub fn render_ui( state.show_page_jump_popup, true, None, - "Seite wählen", + t!("Seite wählen"), ); page_btn_rect = Some(page_resp.rect); if page_resp.clicked() { @@ -3128,7 +3161,7 @@ pub fn render_ui( false, notebook.current_page_index + 1 < notebook.pages.len(), None, - "Nächste Seite (Bild ab)", + t!("Nächste Seite (Bild ab)"), ) .clicked() { @@ -3141,7 +3174,7 @@ pub fn render_ui( false, true, None, - "Neue Seite nach dieser einfügen", + t!("Neue Seite nach dieser einfügen"), ) .clicked() { @@ -3165,9 +3198,9 @@ pub fn render_ui( true, None, if update_available { - "Weitere Funktionen – Update verfügbar" + t!("Weitere Funktionen – Update verfügbar") } else { - "Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen" + t!("Weitere Funktionen: PDF, Hintergrund, Zoom, Einstellungen") }, ); more_btn_rect = Some(more_resp.rect); @@ -3219,10 +3252,13 @@ pub fn render_ui( // Titelzeile mit Schließen-Button ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("Stift {}", state.active_pen + 1)) - .strong() - .color(egui::Color32::WHITE) - .size(12.5), + egui::RichText::new(t!( + "Stift {number}", + number = state.active_pen + 1 + )) + .strong() + .color(egui::Color32::WHITE) + .size(12.5), ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), @@ -3238,7 +3274,7 @@ pub fn render_ui( // Zeile 1: Farbauswahl (Color Swatches) ui.label( - egui::RichText::new("FARBE") + egui::RichText::new(t!("FARBE")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3257,7 +3293,7 @@ pub fn render_ui( // Zeile 2: Dicken-Presets mit visueller Linien-Vorschau ui.label( - egui::RichText::new("DICKEN-PRESETS") + egui::RichText::new(t!("DICKEN-PRESETS")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3280,7 +3316,7 @@ pub fn render_ui( // Zeile 3: Feinjustierung (Slider) ui.label( - egui::RichText::new("STUFENLOSE STÄRKE") + egui::RichText::new(t!("STUFENLOSE STÄRKE")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3319,9 +3355,9 @@ pub fn render_ui( ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!( - "Textmarker – Farbe {}", - state.active_highlighter + 1 + egui::RichText::new(t!( + "Textmarker – Farbe {number}", + number = state.active_highlighter + 1 )) .strong() .color(egui::Color32::WHITE) @@ -3341,7 +3377,7 @@ pub fn render_ui( // Zeile 1: Farbauswahl ui.label( - egui::RichText::new("FARBE") + egui::RichText::new(t!("FARBE")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3360,7 +3396,7 @@ pub fn render_ui( // Zeile 2: Dicken-Presets ui.label( - egui::RichText::new("DICKEN-PRESETS") + egui::RichText::new(t!("DICKEN-PRESETS")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3390,7 +3426,7 @@ pub fn render_ui( // Zeile 3: Slider ui.label( - egui::RichText::new("STUFENLOSE STÄRKE") + egui::RichText::new(t!("STUFENLOSE STÄRKE")) .size(10.5) .strong() .color(egui::Color32::from_gray(160)), @@ -3433,9 +3469,9 @@ pub fn render_ui( touch_friendly_spacing(ui); ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!( - "Textfarbe {}", - state.active_text_color + 1 + egui::RichText::new(t!( + "Textfarbe {number}", + number = state.active_text_color + 1 )) .strong() .color(egui::Color32::WHITE) @@ -3506,7 +3542,7 @@ pub fn render_ui( ui.horizontal(|ui| { ui.label( - egui::RichText::new("Weitere Funktionen") + egui::RichText::new(t!("Weitere Funktionen")) .strong() .color(egui::Color32::WHITE) .size(12.5), @@ -3526,20 +3562,20 @@ pub fn render_ui( egui::ScrollArea::vertical() .max_height((ctx.screen_rect().height() - 90.0).max(150.0)) .show(ui, |ui| { - section(ui, "DOKUMENT"); + section(ui, t!("DOKUMENT")); if ui - .add_sized([ui.available_width(), 30.0], egui::Button::new("📄 Als PDF exportieren (Strg+E)")) + .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Als PDF exportieren (Strg+E)"))) .clicked() { actions.push(UiAction::ExportPdf); state.is_more_menu_open = false; } ui.horizontal(|ui| { - ui.label(egui::RichText::new("Seite als").color(egui::Color32::from_gray(190))); + ui.label(egui::RichText::new(t!("Seite als")).color(egui::Color32::from_gray(190))); for (label, kind, hint) in [ - ("PNG", PageExportKind::Png, "Bild mit Papier und Raster (200 dpi)"), - ("PNG transp.", PageExportKind::PngTransparent, "Bild mit transparentem Hintergrund, nur Inhalt"), - ("SVG", PageExportKind::Svg, "Vektorgrafik, z. B. für Inkscape oder LaTeX"), + ("PNG", PageExportKind::Png, t!("Bild mit Papier und Raster (200 dpi)")), + ("PNG transp.", PageExportKind::PngTransparent, t!("Bild mit transparentem Hintergrund, nur Inhalt")), + ("SVG", PageExportKind::Svg, t!("Vektorgrafik, z. B. für Inkscape oder LaTeX")), ] { if ui.button(label).on_hover_text(hint).clicked() { actions.push(UiAction::ExportPage(kind)); @@ -3548,39 +3584,39 @@ pub fn render_ui( } }); if ui - .add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite einfügen")) - .on_hover_text("Leere Seite direkt nach der aktuellen Seite") + .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen"))) + .on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite")) .clicked() { actions.push(UiAction::AddPage); } delete_page_buttons(ui, state, notebook, &mut actions); 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") + .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("🖼 Bild einfügen … (Strg+I)"))) + .on_hover_text(t!("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")) .clicked() { state.open_image_picker(); } if ui - .add_sized([ui.available_width(), 30.0], egui::Button::new("📄 PDF importieren …")) - .on_hover_text("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben") + .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …"))) + .on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben")) .clicked() { state.open_pdf_picker(); } - section(ui, "HINTERGRUND DIESER SEITE"); + section(ui, t!("HINTERGRUND DIESER SEITE")); let current_bg = notebook.current_page().background; egui::Grid::new("more_menu_backgrounds") .num_columns(2) .spacing([6.0, 6.0]) .show(ui, |ui| { for (i, (bg, label)) in [ - (PageBackground::Grid5mm, "Kariert"), - (PageBackground::Lined, "Liniert"), - (PageBackground::DotGrid, "Punkte"), - (PageBackground::Blank, "Blanko"), + (PageBackground::Grid5mm, t!("Kariert")), + (PageBackground::Lined, t!("Liniert")), + (PageBackground::DotGrid, t!("Punkte")), + (PageBackground::Blank, t!("Blanko")), ] .into_iter() .enumerate() @@ -3600,7 +3636,7 @@ pub fn render_ui( } }); - section(ui, "PAPIERFARBE (NUR ANZEIGE)"); + section(ui, t!("PAPIERFARBE (NUR ANZEIGE)")); let mut paper_changed = false; egui::Grid::new("more_menu_paper") .num_columns(2) @@ -3624,12 +3660,12 @@ pub fn render_ui( state.paper_theme.is_dark(), egui::Checkbox::new( &mut state.adapt_ink_colors, - "Dunkle Stiftfarben aufhellen", + t!("Dunkle Stiftfarben aufhellen"), ), ) .on_hover_text( - "Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \ - als PDF exportiert werden immer die Originalfarben.", + t!("Schwarz wird Weiß, Dunkelblau wird Hellblau. Gespeichert und \ + als PDF exportiert werden immer die Originalfarben."), ) .changed() { @@ -3642,7 +3678,7 @@ pub fn render_ui( }); } - section(ui, "KAROS, LINIEN UND PUNKTE"); + section(ui, t!("KAROS, LINIEN UND PUNKTE")); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); for pattern in PatternColor::ALL { @@ -3661,24 +3697,24 @@ pub fn render_ui( } }); - section(ui, "ANSICHT"); + section(ui, t!("ANSICHT")); if ui - .checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen") + .checkbox(&mut state.continuous_scroll, t!("Seiten durchgehend scrollen")) .on_hover_text( - "An: Seiten hängen untereinander, Mausrad und Finger scrollen \ + t!("An: Seiten hängen untereinander, Mausrad und Finger scrollen \ (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \ - bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", + bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern."), ) .changed() { actions.push(UiAction::SetContinuousScroll(state.continuous_scroll)); } - section(ui, "ZOOM"); + section(ui, t!("ZOOM")); ui.horizontal(|ui| { if ui .add_enabled(state.current_zoom > 0.25, egui::Button::new("🔍 -")) - .on_hover_text("Herauszoomen (Strg+-)") + .on_hover_text(t!("Herauszoomen (Strg+-)")) .clicked() { actions.push(UiAction::ZoomOut); @@ -3686,27 +3722,27 @@ pub fn render_ui( let pct = (state.current_zoom * 100.0).round() as u32; if ui .button(format!("{pct} %")) - .on_hover_text("Zurücksetzen (Strg+0)") + .on_hover_text(t!("Zurücksetzen (Strg+0)")) .clicked() { actions.push(UiAction::ResetZoom); } if ui .add_enabled(state.current_zoom < 5.0, egui::Button::new("🔍 +")) - .on_hover_text("Heranzoomen (Strg++)") + .on_hover_text(t!("Heranzoomen (Strg++)")) .clicked() { actions.push(UiAction::ZoomIn); } }); - section(ui, "EINGABE"); + section(ui, t!("EINGABE")); ui.checkbox( &mut state.touch_draw_enabled, - "Mit dem Finger zeichnen", + t!("Mit dem Finger zeichnen"), ) .on_hover_text( - "Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet.", + t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."), ); if let Some(curve) = pressure_curve_editor(ui, state) { actions.push(UiAction::SavePressureCurve(curve)); @@ -3715,10 +3751,10 @@ pub fn render_ui( actions.push(UiAction::SaveStylusButtons); } - section(ui, "WERKZEUGLEISTE"); + section(ui, t!("WERKZEUGLEISTE")); ui.horizontal(|ui| { for (side, label) in - [(SidebarSide::Left, "Links"), (SidebarSide::Right, "Rechts")] + [(SidebarSide::Left, t!("Links")), (SidebarSide::Right, t!("Rechts"))] { if ui .add_sized( @@ -3737,7 +3773,34 @@ pub fn render_ui( } }); - section(ui, "STATUS"); + // Zweisprachig, damit man die Auswahl in jeder Sprache findet + section(ui, "SPRACHE / LANGUAGE"); + ui.horizontal(|ui| { + let choices = [ + (None, t!("System")), + (Some(crate::i18n::Language::German), "Deutsch"), + (Some(crate::i18n::Language::English), "English"), + ]; + for (language, label) in choices { + if ui + .add_sized( + [75.0, 28.0], + egui::SelectableLabel::new( + state.language == language, + label, + ), + ) + .on_hover_text(t!("Sprache der Oberfläche")) + .clicked() + && state.language != language + { + state.language = language; + actions.push(UiAction::SetLanguage(language)); + } + } + }); + + section(ui, t!("STATUS")); let (dot_color, status_text) = autosave_status_style(autosave_status); ui.horizontal(|ui| { let (dot_rect, _) = ui.allocate_exact_size( @@ -3749,18 +3812,18 @@ pub fn render_ui( }); let update_label = match updater.status() { crate::updater::UpdaterStatus::UpdateAvailable(rel) => { - format!("🚀 Update {} verfügbar", rel.tag_name) + t!("🚀 Update {version} verfügbar", version = rel.tag_name) } crate::updater::UpdaterStatus::Downloading { percent, .. } => { - format!("⏳ Update lädt … {:.0} %", percent * 100.0) + t!("⏳ Update lädt … {percent} %", percent = format!("{:.0}", percent * 100.0)) } crate::updater::UpdaterStatus::DownloadFinished { .. } => { - "✔ Update bereit".to_string() + t!("✔ Update bereit").to_string() } crate::updater::UpdaterStatus::Checking => { - "🔄 Suche nach Updates …".to_string() + t!("🔄 Suche nach Updates …").to_string() } - _ => "🔄 Updates & Versionen".to_string(), + _ => t!("🔄 Updates & Versionen").to_string(), }; if ui .add_sized([ui.available_width(), 30.0], egui::Button::new(update_label)) @@ -3793,7 +3856,7 @@ pub fn render_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()); + let title = t!("Seiten ({count})", count = notebook.pages.len()); if dialog_header(ui, &title, true) { state.show_page_jump_popup = false; } @@ -3804,9 +3867,9 @@ pub fn render_ui( state.page_title_input = current.title.clone().unwrap_or_default(); } ui.label( - egui::RichText::new(format!( - "TITEL VON SEITE {}", - notebook.current_page_index + 1 + egui::RichText::new(t!( + "TITEL VON SEITE {number}", + number = notebook.current_page_index + 1 )) .size(10.5) .strong() @@ -3814,7 +3877,7 @@ pub fn render_ui( ); let field = ui.add( egui::TextEdit::singleline(&mut state.page_title_input) - .hint_text("z. B. Kapitel 2 – Integrale") + .hint_text(t!("z. B. Kapitel 2 – Integrale")) .margin(egui::vec2(8.0, 8.0)) .desired_width(f32::INFINITY), ); @@ -3827,7 +3890,8 @@ pub fn render_ui( } ui.add_space(2.0); ui.horizontal(|ui| { - for (list, label) in [(false, "🖼 Kacheln"), (true, "📑 Inhalt")] + for (list, label) in + [(false, t!("🖼 Kacheln")), (true, t!("📑 Inhalt"))] { if ui .add_sized( @@ -3894,9 +3958,9 @@ pub fn render_ui( if ui .add_sized( [ui.available_width(), 36.0], - egui::Button::new("➕ Neue Seite einfügen"), + egui::Button::new(t!("➕ Neue Seite einfügen")), ) - .on_hover_text("Leere Seite direkt nach der aktuellen Seite") + .on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite")) .clicked() { actions.push(UiAction::AddPage); @@ -3938,11 +4002,11 @@ pub fn render_ui( // 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) + let doc_win = dialog_window(t!("Dokumenten-Manager"), state, false) .show(ctx, |ui| { ui.set_width(420.0); touch_friendly_spacing(ui); - if dialog_header(ui, "📚 Notizbücher", true) { + if dialog_header(ui, t!("📚 Notizbücher"), true) { state.is_document_menu_open = false; } @@ -3960,16 +4024,16 @@ pub fn render_ui( ui.horizontal(|ui| { let text_edit = ui.add( egui::TextEdit::singleline(&mut state.new_notebook_title_input) - .hint_text("Name …") + .hint_text(t!("Name …")) .margin(egui::vec2(8.0, 8.0)) .desired_width(190.0), ); let create_btn = ui - .button(egui::RichText::new("➕ Notizbuch").strong()) - .on_hover_text("Neues Notizbuch in diesem Ordner anlegen und öffnen"); + .button(egui::RichText::new(t!("➕ Notizbuch")).strong()) + .on_hover_text(t!("Neues Notizbuch in diesem Ordner anlegen und öffnen")); let folder_btn = ui - .button(egui::RichText::new("📁 Ordner").strong()) - .on_hover_text("Neuen Ordner hier anlegen (z. B. Semester oder Fach)"); + .button(egui::RichText::new(t!("📁 Ordner")).strong()) + .on_hover_text(t!("Neuen Ordner hier anlegen (z. B. Semester oder Fach)")); let title = state.new_notebook_title_input.trim().to_string(); if !title.is_empty() { if create_btn.clicked() @@ -4022,7 +4086,7 @@ pub fn render_ui( } if subfolders.is_empty() && summaries.is_empty() { ui.label( - egui::RichText::new("Dieser Ordner ist leer.") + egui::RichText::new(t!("Dieser Ordner ist leer.")) .color(egui::Color32::from_gray(150)), ); } @@ -4080,15 +4144,15 @@ pub fn render_ui( 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") + .button(egui::RichText::new(t!("🔄 Backup")).size(13.0).color(egui::Color32::from_rgb(100, 200, 255))) + .on_hover_text(t!("Aus manifest.json.bak wiederherstellen")) .clicked() { actions.push(UiAction::RestoreNotebookBackup(summary.id.clone())); } if ui - .button(egui::RichText::new("📁 Ordner").size(13.0)) - .on_hover_text("Pfad in Statusleiste anzeigen") + .button(egui::RichText::new(t!("📁 Ordner")).size(13.0)) + .on_hover_text(t!("Pfad in Statusleiste anzeigen")) .clicked() { actions.push(UiAction::OpenNotebookFolder(summary.id.clone())); @@ -4097,7 +4161,7 @@ pub fn render_ui( 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") + .on_hover_text(t!("Notizbuch in Papierkorb verschieben")) .clicked() { actions.push(UiAction::DeleteNotebook(summary.id.clone())); @@ -4105,14 +4169,14 @@ pub fn render_ui( if ui .button(egui::RichText::new("📂").size(16.0)) - .on_hover_text("In einen anderen Ordner verschieben") + .on_hover_text(t!("In einen anderen Ordner verschieben")) .clicked() { state.moving = Some(MoveSubject::Notebook(summary.id.clone())); } if ui.button(egui::RichText::new("✏").size(16.0)) - .on_hover_text("Titel umbenennen") + .on_hover_text(t!("Titel umbenennen")) .clicked() { state.renaming_notebook_id = Some(summary.id.clone()); @@ -4132,15 +4196,15 @@ pub fn render_ui( .color(egui::Color32::WHITE), ); if summary.status == crate::storage::NotebookStatus::Damaged { - ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt"); + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), t!("⚠ Beschädigt")); } else if is_current { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv"); + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), t!("⚫ Aktiv")); } }); let subtext = if let Some(ref err) = summary.error { - format!("Fehler: {err}") + t!("Fehler: {err}", err = err) } else { - format!("{} Seite(n)", summary.page_count) + t!("{count} Seite(n)", count = summary.page_count) }; ui.label( egui::RichText::new(subtext) @@ -4175,12 +4239,12 @@ pub fn render_ui( ui.add_space(4.0); ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("'{title}' gelöscht")) + egui::RichText::new(t!("'{title}' gelöscht", title = title)) .size(11.0) .color(egui::Color32::from_rgb(255, 180, 100)), ); if ui - .button(egui::RichText::new("↩ Rückgängig").size(13.0).color(egui::Color32::from_rgb(100, 200, 255))) + .button(egui::RichText::new(t!("↩ Rückgängig")).size(13.0).color(egui::Color32::from_rgb(100, 200, 255))) .clicked() { actions.push(UiAction::UndoDeleteNotebook); @@ -4200,11 +4264,11 @@ pub fn render_ui( // 3c. Release- & Update-Center (Gitea Releases) if state.is_update_dialog_open { - let win = dialog_window("Release- & Update-Center", state, false) + let win = dialog_window(t!("Release- & Update-Center"), state, false) .show(ctx, |ui| { ui.set_width(460.0); touch_friendly_spacing(ui); - if dialog_header(ui, "🚀 Updates & Versionen", true) { + if dialog_header(ui, t!("🚀 Updates & Versionen"), true) { state.is_update_dialog_open = false; } @@ -4212,13 +4276,13 @@ pub fn render_ui( 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(t!("Aktuelle Version:")).color(egui::Color32::from_gray(180))); ui.label(egui::RichText::new(format!("v{}", current_ver)).strong().color(egui::Color32::WHITE)); ui.label(egui::RichText::new(format!("({})", runtime_name)).color(egui::Color32::from_gray(150))); }); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Server:").color(egui::Color32::from_gray(180))); + ui.label(egui::RichText::new(t!("Server:")).color(egui::Color32::from_gray(180))); ui.label(egui::RichText::new(format!("{}/{}/{}", updater.config().server_url, updater.config().repo_owner, updater.config().repo_name)).color(egui::Color32::from_gray(210))); }); @@ -4227,7 +4291,7 @@ pub fn render_ui( // 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() { + if ui.add_enabled(!is_busy, egui::Button::new(egui::RichText::new(t!("🔄 Nach Updates suchen")).strong())).clicked() { actions.push(UiAction::CheckForUpdates); } }); @@ -4240,21 +4304,21 @@ pub fn render_ui( 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.") + egui::RichText::new(t!("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.")) .color(egui::Color32::from_gray(170)), ); } crate::updater::UpdaterStatus::Checking => { ui.horizontal(|ui| { ui.spinner(); - ui.label(egui::RichText::new("Prüfe Gitea-Releases...").color(egui::Color32::WHITE)); + ui.label(egui::RichText::new(t!("Prüfe Gitea-Releases...")).color(egui::Color32::WHITE)); }); } crate::updater::UpdaterStatus::UpToDate { current_version } => { ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔"); ui.label( - egui::RichText::new(format!("ink-notes ist auf dem neuesten Stand (v{}).", current_version)) + egui::RichText::new(t!("ink-notes ist auf dem neuesten Stand (v{version}).", version = current_version)) .color(egui::Color32::from_rgb(46, 204, 113)) .strong(), ); @@ -4265,7 +4329,7 @@ pub fn render_ui( ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(231, 76, 60), "⚠"); ui.label( - egui::RichText::new("Abfrage fehlgeschlagen:") + egui::RichText::new(t!("Abfrage fehlgeschlagen:")) .color(egui::Color32::from_rgb(231, 76, 60)) .strong(), ); @@ -4278,7 +4342,7 @@ pub fn render_ui( ); ui.add_space(4.0); ui.label( - egui::RichText::new("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.") + egui::RichText::new(t!("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.")) .size(11.0) .italics() .color(egui::Color32::from_gray(140)), @@ -4290,7 +4354,7 @@ pub fn render_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)) + egui::RichText::new(t!("Neue Version verfügbar: {version}", version = release.tag_name)) .strong() .size(14.0) .color(egui::Color32::WHITE), @@ -4303,14 +4367,14 @@ pub fn render_ui( 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))); + ui.label(egui::RichText::new(t!("Änderungen:")).strong().size(11.5).color(egui::Color32::from_gray(170))); egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| { ui.label(egui::RichText::new(&release.body).size(11.5).color(egui::Color32::from_gray(210))); }); } ui.add_space(10.0); - ui.label(egui::RichText::new("VERFÜGBARE ARTEFAKTE:").size(10.5).strong().color(egui::Color32::from_gray(150))); + ui.label(egui::RichText::new(t!("VERFÜGBARE ARTEFAKTE:")).size(10.5).strong().color(egui::Color32::from_gray(150))); ui.add_space(4.0); for asset in &release.assets { @@ -4341,14 +4405,14 @@ pub fn render_ui( } 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.label(egui::RichText::new(t!("(Empfohlen für dieses System)")).size(10.5).color(egui::Color32::from_rgb(46, 204, 113))); } }); } ui.add_space(8.0); if !release.html_url.is_empty() - && ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() + && ui.button(egui::RichText::new(t!("🌐 Release auf Gitea öffnen")).size(12.0)).clicked() { actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone())); } @@ -4356,7 +4420,7 @@ pub fn render_ui( } crate::updater::UpdaterStatus::Downloading { asset_name, percent } => { ui.vertical(|ui| { - ui.label(egui::RichText::new(format!("Lade '{}' herunter...", asset_name)).strong()); + ui.label(egui::RichText::new(t!("Lade '{name}' herunter...", name = asset_name)).strong()); ui.add_space(6.0); ui.add(egui::ProgressBar::new(*percent).show_percentage()); }); @@ -4366,30 +4430,30 @@ pub fn render_ui( ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✔"); ui.label( - egui::RichText::new(format!("'{}' erfolgreich heruntergeladen!", asset_name)) + egui::RichText::new(t!("'{name}' erfolgreich heruntergeladen!", name = asset_name)) .strong() .color(egui::Color32::from_rgb(46, 204, 113)), ); }); ui.add_space(4.0); ui.label( - egui::RichText::new(format!("Gespeichert in: {}", file_path.display())) + egui::RichText::new(t!("Gespeichert in: {path}", path = file_path.display())) .size(11.0) .color(egui::Color32::from_gray(170)), ); ui.add_space(8.0); let action_label = match updater.runtime() { - crate::updater::RuntimeEnvironment::AppImage { .. } => "🚀 AppImage aktualisieren & ausführen", - crate::updater::RuntimeEnvironment::DebPackage { .. } => "📦 Paket mit Software-Center öffnen / installieren", - crate::updater::RuntimeEnvironment::PortableOrDev { .. } => "📂 Im Dateimanager anzeigen", + crate::updater::RuntimeEnvironment::AppImage { .. } => t!("🚀 AppImage aktualisieren & ausführen"), + crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"), + crate::updater::RuntimeEnvironment::PortableOrDev { .. } => t!("📂 Im Dateimanager anzeigen"), }; ui.horizontal(|ui| { if ui.button(egui::RichText::new(action_label).strong()).clicked() { actions.push(UiAction::InstallOrOpenUpdate(file_path.clone())); } - if ui.button("📂 Ordner öffnen").clicked() { + if ui.button(t!("📂 Ordner öffnen")).clicked() { if let Some(parent) = file_path.parent() { actions.push(UiAction::OpenUrlInBrowser(parent.to_string_lossy().to_string())); } @@ -4404,19 +4468,19 @@ pub fn render_ui( 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))) + egui::CollapsingHeader::new(egui::RichText::new(t!("⚙ Erweiterte Server-Einstellungen")).size(11.5).color(egui::Color32::from_gray(160))) .default_open(false) .show(ui, |ui| { ui.add_space(4.0); - ui.label(egui::RichText::new("Gitea Server-URL:").size(11.0)); + ui.label(egui::RichText::new(t!("Gitea Server-URL:")).size(11.0)); ui.add(egui::TextEdit::singleline(&mut state.updater_server_url_input).desired_width(380.0)); ui.add_space(4.0); - ui.label(egui::RichText::new("Personal Access Token (optional für private Repos):").size(11.0)); + ui.label(egui::RichText::new(t!("Personal Access Token (optional für private Repos):")).size(11.0)); ui.add(egui::TextEdit::singleline(&mut state.updater_token_input).password(true).desired_width(380.0)); ui.add_space(6.0); - if ui.button(egui::RichText::new("💾 Einstellungen speichern").size(11.5)).clicked() { + if ui.button(egui::RichText::new(t!("💾 Einstellungen speichern")).size(11.5)).clicked() { let token = if state.updater_token_input.trim().is_empty() { None } else { @@ -4485,16 +4549,16 @@ pub fn render_ui( // 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) + dialog_window(t!("⚠ Datenintegritäts-Warnung"), state, true) .show(ctx, |ui| { ui.set_width(420.0); touch_friendly_spacing(ui); ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠"); ui.label( - egui::RichText::new(format!( - "Seite {} konnte nicht fehlerfrei geladen werden!", - warning.page_id + egui::RichText::new(t!( + "Seite {id} konnte nicht fehlerfrei geladen werden!", + id = warning.page_id )) .strong() .size(15.0) @@ -4503,14 +4567,14 @@ pub fn render_ui( }); ui.add_space(8.0); ui.label( - egui::RichText::new(format!("Fehlermeldung: {}", warning.message)) + egui::RichText::new(t!("Fehlermeldung: {message}", message = warning.message)) .size(12.0) .color(egui::Color32::from_gray(200)), ); ui.add_space(8.0); if let Some(ref q_path) = warning.quarantine_path { ui.label( - egui::RichText::new("Die beschädigte Originaldatei wurde in Quarantäne gesichert:") + egui::RichText::new(t!("Die beschädigte Originaldatei wurde in Quarantäne gesichert:")) .size(12.0) .color(egui::Color32::from_gray(220)), ); @@ -4523,8 +4587,8 @@ pub fn render_ui( 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.", + t!("Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\ + Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben."), ) .size(11.5) .color(egui::Color32::from_gray(180)), @@ -4532,7 +4596,7 @@ pub fn render_ui( ui.add_space(12.0); if ui .button( - egui::RichText::new("Als leere Seite bearbeiten (Schreibschutz aufheben)") + egui::RichText::new(t!("Als leere Seite bearbeiten (Schreibschutz aufheben)")) .color(egui::Color32::WHITE) .size(12.5), ) @@ -4543,8 +4607,8 @@ pub fn render_ui( } 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.", + t!("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\ + Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern."), ) .size(12.0) .color(egui::Color32::from_gray(200)), @@ -4556,13 +4620,13 @@ pub fn render_ui( // 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| { + dialog_window(t!("⚠ Ungespeicherte Änderungen"), state, true).show(ctx, |ui| { ui.set_width(460.0); touch_friendly_spacing(ui); ui.horizontal(|ui| { ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠"); ui.label( - egui::RichText::new("Fehler beim Beenden") + egui::RichText::new(t!("Fehler beim Beenden")) .color(egui::Color32::from_rgb(255, 100, 100)) .size(16.0) .strong(), @@ -4570,9 +4634,11 @@ pub fn render_ui( }); ui.add_space(8.0); ui.label( - egui::RichText::new("Ungespeicherte Änderungen konnten nicht gesichert werden:") - .size(13.0) - .color(egui::Color32::WHITE), + egui::RichText::new(t!( + "Ungespeicherte Änderungen konnten nicht gesichert werden:" + )) + .size(13.0) + .color(egui::Color32::WHITE), ); ui.add_space(6.0); egui::Frame::none() @@ -4590,24 +4656,24 @@ pub fn render_ui( }); ui.add_space(8.0); ui.label( - egui::RichText::new("Trotzdem beenden?") + egui::RichText::new(t!("Trotzdem beenden?")) .size(13.0) .color(egui::Color32::WHITE), ); ui.add_space(12.0); ui.horizontal_wrapped(|ui| { - if ui.button("Abbrechen").clicked() { + if ui.button(t!("Abbrechen")).clicked() { dismiss = true; } - if ui.button("Wiederholen").clicked() { + if ui.button(t!("Wiederholen")).clicked() { actions.push(UiAction::RetryShutdown); } - if ui.button("Notfall-Export").clicked() { + if ui.button(t!("Notfall-Export")).clicked() { actions.push(UiAction::ExportRecoveryAndExit); } if ui .button( - egui::RichText::new("Trotzdem beenden") + egui::RichText::new(t!("Trotzdem beenden")) .color(egui::Color32::from_rgb(255, 100, 100)), ) .clicked() diff --git a/src/updater.rs b/src/updater.rs index af51818..d9f17c9 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -23,6 +23,8 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::t; + /// Konfiguration für den Gitea-Update-Server. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UpdaterConfig { @@ -59,13 +61,18 @@ impl Default for UpdaterConfig { impl UpdaterConfig { /// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS). pub fn validate_url(&self) -> Result { - let parsed = url::Url::parse(&self.server_url) - .map_err(|e| format!("Ungültige Server-URL '{}': {e}", self.server_url))?; + let parsed = url::Url::parse(&self.server_url).map_err(|e| { + t!( + "Ungültige Server-URL '{url}': {e}", + url = self.server_url, + e = e + ) + })?; if parsed.scheme() != "https" && !self.allow_insecure_http { - return Err(format!( - "Unsicheres Protokoll '{}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", - parsed.scheme() + return Err(t!( + "Unsicheres Protokoll '{scheme}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", + scheme = parsed.scheme() )); } Ok(parsed) @@ -146,9 +153,9 @@ impl RuntimeEnvironment { pub fn display_name(&self) -> &'static str { match self { - RuntimeEnvironment::AppImage { .. } => "AppImage (Portabel)", - RuntimeEnvironment::DebPackage { .. } => "System-Paket (.deb)", - RuntimeEnvironment::PortableOrDev { .. } => "Entwicklung / Lokal", + RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"), + RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"), + RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"), } } } @@ -157,22 +164,24 @@ impl RuntimeEnvironment { pub fn sanitize_asset_name(raw_name: &str) -> Result { let trimmed = raw_name.trim(); if trimmed.is_empty() { - return Err("Asset-Name darf nicht leer sein.".to_string()); + return Err(t!("Asset-Name darf nicht leer sein.").to_string()); } if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { - return Err(format!( - "Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'" + return Err(t!( + "Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'", + raw_name = raw_name )); } let path = Path::new(trimmed); let file_name = path .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| format!("Ungültiger Dateiname: '{raw_name}'"))?; + .ok_or_else(|| t!("Ungültiger Dateiname: '{raw_name}'", raw_name = raw_name))?; if file_name != trimmed || file_name.starts_with('.') { - return Err(format!( - "Ungültiger oder versteckter Asset-Name: '{raw_name}'" + return Err(t!( + "Ungültiger oder versteckter Asset-Name: '{raw_name}'", + raw_name = raw_name )); } Ok(file_name.to_string()) @@ -193,9 +202,10 @@ pub fn get_updates_cache_dir() -> Result { let dir = base.join("ink-notes").join("updates"); std::fs::create_dir_all(&dir).map_err(|e| { - format!( - "Konnte Update-Cache-Verzeichnis nicht anlegen ({}): {e}", - dir.display() + t!( + "Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}", + path = dir.display(), + e = e ) })?; @@ -210,7 +220,7 @@ pub fn get_updates_cache_dir() -> Result { fn default_user_cache_dir() -> Result { let home = std::env::var("HOME") - .map_err(|_| "HOME-Umgebungsvariable ist nicht gesetzt.".to_string())?; + .map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?; Ok(PathBuf::from(home).join(".cache")) } @@ -251,9 +261,10 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases /// Berechnet den hex-kodierten SHA-256 Hash einer Datei. pub fn compute_sha256(path: &Path) -> Result { let mut file = std::fs::File::open(path).map_err(|e| { - format!( - "Konnte Datei '{}' zur Prüfsummenberechnung nicht öffnen: {e}", - path.display() + t!( + "Konnte Datei '{path}' zur Prüfsummenberechnung nicht öffnen: {e}", + path = path.display(), + e = e ) })?; let mut hasher = Sha256::new(); @@ -261,7 +272,7 @@ pub fn compute_sha256(path: &Path) -> Result { loop { let n = file .read(&mut buffer) - .map_err(|e| format!("Fehler beim Lesen der Prüfsummendatei: {e}"))?; + .map_err(|e| t!("Fehler beim Lesen der Prüfsummendatei: {e}", e = e))?; if n == 0 { break; } @@ -300,15 +311,13 @@ pub fn verify_checksum( let checksums = parse_checksums(checksums_content); let expected_hash = checksums.get(asset_name).ok_or_else(|| { let _ = std::fs::remove_file(downloaded_path); - format!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.") + t!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", asset_name = asset_name) })?; let actual_hash = compute_sha256(downloaded_path)?; if !actual_hash.eq_ignore_ascii_case(expected_hash) { let _ = std::fs::remove_file(downloaded_path); - return Err(format!( - "Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht." - )); + return Err(t!("Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht.", asset_name = asset_name, expected_hash = expected_hash, actual_hash = actual_hash)); } Ok(()) } @@ -334,15 +343,26 @@ pub fn verify_checksums_signature( ) -> Result<(), String> { let key = public_key_b64.trim(); if key.is_empty() { - return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string()); + return Err(t!("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.").to_string()); } let public_key = minisign_verify::PublicKey::from_base64(key) - .map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?; - let signature = minisign_verify::Signature::decode(signature_text) - .map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?; + .map_err(|e| t!("Ungültiger Update-Signaturschlüssel: {e}.", e = e))?; + let signature = minisign_verify::Signature::decode(signature_text).map_err(|e| { + t!( + "{file} ist ungültig: {e}.", + file = CHECKSUMS_SIGNATURE_ASSET_NAME, + e = e + ) + })?; public_key .verify(checksums, &signature, false) - .map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}.")) + .map_err(|e| { + t!( + "Signaturprüfung von {file} fehlgeschlagen: {e}.", + file = CHECKSUMS_ASSET_NAME, + e = e + ) + }) } /// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client. @@ -356,7 +376,7 @@ fn fetch_release_text( .assets .iter() .find(|a| a.name == name) - .ok_or_else(|| format!("Release enthält keine {name}."))?; + .ok_or_else(|| t!("Release enthält keine {name}.", name = name))?; http_get_with_auth_guard( agent, &asset.browser_download_url, @@ -364,9 +384,9 @@ fn fetch_release_text( 5, config.allow_insecure_http, ) - .map_err(|e| format!("Konnte {name} nicht laden: {e}."))? + .map_err(|e| t!("Konnte {name} nicht laden: {e}.", name = name, e = e))? .into_string() - .map_err(|e| format!("Konnte {name} nicht dekodieren: {e}.")) + .map_err(|e| t!("Konnte {name} nicht dekodieren: {e}.", name = name, e = e)) } /// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger @@ -429,9 +449,7 @@ fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), match url.scheme() { "https" => Ok(()), "http" if allow_insecure_http => Ok(()), - scheme => Err(format!( - "Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt." - )), + scheme => Err(t!("Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt.", scheme = scheme, url = url)), } } @@ -453,8 +471,13 @@ fn http_get_with_auth_guard( max_redirects: usize, allow_insecure_http: bool, ) -> Result { - let mut current_url = url::Url::parse(initial_url_str) - .map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?; + let mut current_url = url::Url::parse(initial_url_str).map_err(|e| { + t!( + "Ungültige URL '{initial_url_str}': {e}", + initial_url_str = initial_url_str, + e = e + ) + })?; let initial_origin = current_url.origin().ascii_serialization(); for _ in 0..=max_redirects { @@ -476,24 +499,35 @@ fn http_get_with_auth_guard( Ok(resp) => return Ok(resp), Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => { let loc = resp.header("Location").ok_or_else(|| { - format!("HTTP {code} Weiterleitung ohne Location-Header erhalten") + t!( + "HTTP {code} Weiterleitung ohne Location-Header erhalten", + code = code + ) + })?; + current_url = current_url.join(loc).map_err(|e| { + t!( + "Ungültiges Weiterleitungsziel '{loc}': {e}", + loc = loc, + e = e + ) })?; - current_url = current_url - .join(loc) - .map_err(|e| format!("Ungültiges Weiterleitungsziel '{loc}': {e}"))?; continue; } Err(ureq::Error::Status(code, resp)) => { let body = resp.into_string().unwrap_or_default(); - return Err(format!("Gitea-Server antwortete mit HTTP {code}: {body}")); + return Err(t!( + "Gitea-Server antwortete mit HTTP {code}: {body}", + code = code, + body = body + )); } Err(ureq::Error::Transport(t)) => { - return Err(format!("Netzwerk-Transportfehler: {t}")); + return Err(t!("Netzwerk-Transportfehler: {t}", t = t)); } } } - Err("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).".to_string()) + Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string()) } /// Asynchroner Controller für Update-Prüfung und Asset-Download. @@ -592,8 +626,9 @@ impl UpdaterController { } } Err(e) => { - let _ = tx.send(UpdaterEvent::CheckError(format!( - "Gitea Release-JSON konnte nicht verarbeitet werden: {e}" + let _ = tx.send(UpdaterEvent::CheckError(t!( + "Gitea Release-JSON konnte nicht verarbeitet werden: {e}", + e = e ))); } }, @@ -649,15 +684,16 @@ impl UpdaterController { ) { Ok(text) => text, Err(e) => { - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "{e} Download abgelehnt." + let _ = tx.send(UpdaterEvent::DownloadError(t!( + "{e} Download abgelehnt.", + e = e ))); return; } }, None => { let _ = tx.send(UpdaterEvent::DownloadError( - "Kein Release ausgewählt. Download abgelehnt.".to_string(), + t!("Kein Release ausgewählt. Download abgelehnt.").to_string(), )); return; } @@ -673,8 +709,9 @@ impl UpdaterController { ) { Ok(r) => r, Err(e) => { - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Download-Verbindungsfehler: {e}" + let _ = tx.send(UpdaterEvent::DownloadError(t!( + "Download-Verbindungsfehler: {e}", + e = e ))); return; } @@ -686,9 +723,10 @@ impl UpdaterController { let mut file = match std::fs::File::create(&target_file) { Ok(f) => f, Err(e) => { - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Konnte Zieldatei '{}' nicht erstellen: {e}", - target_file.display() + let _ = tx.send(UpdaterEvent::DownloadError(t!( + "Konnte Zieldatei '{path}' nicht erstellen: {e}", + path = target_file.display(), + e = e ))); return; } @@ -705,8 +743,9 @@ impl UpdaterController { Ok(count) => count, Err(e) => { let _ = std::fs::remove_file(&target_file); - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Fehler beim Lesen des Download-Streams: {e}" + let _ = tx.send(UpdaterEvent::DownloadError(t!( + "Fehler beim Lesen des Download-Streams: {e}", + e = e ))); return; } @@ -714,8 +753,9 @@ impl UpdaterController { if let Err(e) = file.write_all(&buffer[..n]) { let _ = std::fs::remove_file(&target_file); - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Fehler beim Schreiben auf die Festplatte: {e}" + let _ = tx.send(UpdaterEvent::DownloadError(t!( + "Fehler beim Schreiben auf die Festplatte: {e}", + e = e ))); return; } @@ -776,18 +816,19 @@ impl UpdaterController { /// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System. pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> { if !file_path.exists() { - return Err("Die heruntergeladene Datei existiert nicht mehr.".to_string()); + return Err(t!("Die heruntergeladene Datei existiert nicht mehr.").to_string()); } match &self.runtime { RuntimeEnvironment::AppImage { appimage_path } => { let parent_dir = appimage_path.parent().ok_or_else(|| { - "Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.".to_string() + t!("Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.") + .to_string() })?; let file_name = appimage_path .file_name() - .ok_or_else(|| "Ungültiger AppImage-Dateiname.".to_string())? + .ok_or_else(|| t!("Ungültiger AppImage-Dateiname.").to_string())? .to_string_lossy(); let temp_new_file = @@ -797,8 +838,9 @@ impl UpdaterController { // 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename) if let Err(e) = std::fs::copy(file_path, &temp_new_file) { let _ = std::fs::remove_file(&temp_new_file); - return Err(format!( - "Konnte temporäre Update-Datei nicht schreiben: {e}" + return Err(t!( + "Konnte temporäre Update-Datei nicht schreiben: {e}", + e = e )); } @@ -816,18 +858,17 @@ impl UpdaterController { // 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren! if let Err(e) = std::fs::copy(appimage_path, &backup_file) { let _ = std::fs::remove_file(&temp_new_file); - return Err(format!( - "Konnte Backup ({}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", - backup_file.display() + return Err(t!( + "Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", + path = backup_file.display(), + e = e )); } // 4. Atomar ersetzen via rename if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) { let _ = std::fs::remove_file(&temp_new_file); - return Err(format!( - "Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert." - )); + return Err(t!("Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert.", e = e)); } // Heruntergeladene Cache-Datei aufräumen @@ -837,7 +878,7 @@ impl UpdaterController { RuntimeEnvironment::DebPackage { .. } => { match Command::new("xdg-open").arg(file_path).spawn() { Ok(_) => Ok(()), - Err(e) => Err(format!("Konnte xdg-open nicht ausführen: {e}")), + Err(e) => Err(t!("Konnte xdg-open nicht ausführen: {e}", e = e)), } } RuntimeEnvironment::PortableOrDev { .. } => {