Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 |
Generated
+1
-1
@@ -1048,7 +1048,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.2.3"
|
version = "0.3.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.2.3"
|
version = "0.3.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
|
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||||
|
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||||
|
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||||
|
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||||
|
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||||
|
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||||
|
|||||||
+6
-1
@@ -312,7 +312,12 @@ impl AutosaveController {
|
|||||||
|
|
||||||
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
||||||
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
||||||
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
// Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
|
||||||
|
// der Seitendatei überschreiben
|
||||||
|
if self.read_only
|
||||||
|
|| !page.is_loaded
|
||||||
|
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let page_data = PageData::from(page);
|
let page_data = PageData::from(page);
|
||||||
|
|||||||
+121
-35
@@ -87,13 +87,43 @@ pub fn spline_vertices_to_ndc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
/// 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\
|
||||||
|
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`.";
|
||||||
|
|
||||||
|
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
|
||||||
|
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
||||||
|
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
|
||||||
|
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
|
||||||
|
let preferred: &[wgpu::PresentMode] = if force_fifo {
|
||||||
|
&[wgpu::PresentMode::Fifo]
|
||||||
|
} else {
|
||||||
|
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
|
||||||
|
};
|
||||||
|
preferred
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|m| available.contains(m))
|
||||||
|
.or_else(|| available.first().copied())
|
||||||
|
}
|
||||||
|
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub surface: wgpu::Surface<'static>,
|
pub surface: wgpu::Surface<'static>,
|
||||||
|
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||||
|
adapter: wgpu::Adapter,
|
||||||
|
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||||
|
presentation_warned: bool,
|
||||||
pub device: wgpu::Device,
|
pub device: wgpu::Device,
|
||||||
pub queue: wgpu::Queue,
|
pub queue: wgpu::Queue,
|
||||||
pub config: wgpu::SurfaceConfiguration,
|
pub config: wgpu::SurfaceConfiguration,
|
||||||
pub size: PhysicalSize<u32>,
|
pub size: PhysicalSize<u32>,
|
||||||
pub viewport: PageViewport,
|
pub viewport: PageViewport,
|
||||||
|
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||||
|
ui_inset_left: f32,
|
||||||
|
ui_inset_right: f32,
|
||||||
pub camera: crate::viewport::ViewportState,
|
pub camera: crate::viewport::ViewportState,
|
||||||
|
|
||||||
// Pipelines
|
// Pipelines
|
||||||
@@ -146,23 +176,47 @@ impl CanvasState {
|
|||||||
let viewport =
|
let viewport =
|
||||||
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||||
|
|
||||||
// 1. WGPU Instance & Surface
|
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
|
||||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||||
backends: wgpu::Backends::all(),
|
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let surface = instance.create_surface(window.clone())?;
|
let surface = instance.create_surface(window.clone())?;
|
||||||
|
|
||||||
// 2. Adapter auswählen (hohe Performance bevorzugt)
|
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
|
||||||
let adapter = instance
|
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
|
||||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
|
||||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
let mut chosen = None;
|
||||||
compatible_surface: Some(&surface),
|
for force_fallback_adapter in [false, true] {
|
||||||
force_fallback_adapter: false,
|
let Some(adapter) = instance
|
||||||
})
|
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||||
.await
|
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||||
.ok_or("Kein passender GPU-Adapter gefunden")?;
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let caps = surface.get_capabilities(&adapter);
|
||||||
|
match choose_present_mode(&caps.present_modes) {
|
||||||
|
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
|
||||||
|
eprintln!(
|
||||||
|
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
|
||||||
|
info.name, info.backend, mode
|
||||||
|
);
|
||||||
|
chosen = Some((adapter, caps, mode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => eprintln!(
|
||||||
|
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
|
||||||
|
info.name, info.backend
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
|
||||||
|
|
||||||
// 3. Device & Queue erstellen
|
// 3. Device & Queue erstellen
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
@@ -176,8 +230,7 @@ impl CanvasState {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
|
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||||
let surface_caps = surface.get_capabilities(&adapter);
|
|
||||||
let surface_format = surface_caps
|
let surface_format = surface_caps
|
||||||
.formats
|
.formats
|
||||||
.iter()
|
.iter()
|
||||||
@@ -185,20 +238,6 @@ impl CanvasState {
|
|||||||
.find(|f| f.is_srgb())
|
.find(|f| f.is_srgb())
|
||||||
.unwrap_or(surface_caps.formats[0]);
|
.unwrap_or(surface_caps.formats[0]);
|
||||||
|
|
||||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
|
||||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
|
||||||
wgpu::PresentMode::Fifo
|
|
||||||
} else if surface_caps
|
|
||||||
.present_modes
|
|
||||||
.contains(&wgpu::PresentMode::Mailbox)
|
|
||||||
{
|
|
||||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
|
||||||
wgpu::PresentMode::Mailbox
|
|
||||||
} else {
|
|
||||||
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
|
|
||||||
wgpu::PresentMode::Fifo
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = wgpu::SurfaceConfiguration {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
format: surface_format,
|
format: surface_format,
|
||||||
@@ -366,11 +405,15 @@ impl CanvasState {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
|
adapter,
|
||||||
|
presentation_warned: false,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
config,
|
config,
|
||||||
size,
|
size,
|
||||||
viewport,
|
viewport,
|
||||||
|
ui_inset_left: 0.0,
|
||||||
|
ui_inset_right: 0.0,
|
||||||
strip_pipeline,
|
strip_pipeline,
|
||||||
triangle_pipeline,
|
triangle_pipeline,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
@@ -418,21 +461,54 @@ impl CanvasState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||||
|
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||||
|
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.ui_inset_left = left;
|
||||||
|
self.ui_inset_right = right;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate_viewport(&mut self) {
|
||||||
|
self.viewport = PageViewport::calculate_with_insets(
|
||||||
|
self.size.width as f32,
|
||||||
|
self.size.height as f32,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
self.ui_inset_left,
|
||||||
|
self.ui_inset_right,
|
||||||
|
);
|
||||||
|
self.update_uniforms();
|
||||||
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||||
if new_size.width > 0 && new_size.height > 0 {
|
if new_size.width > 0 && new_size.height > 0 {
|
||||||
self.size = new_size;
|
self.size = new_size;
|
||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
|
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||||
|
let caps = self.surface.get_capabilities(&self.adapter);
|
||||||
|
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||||
|
match choose_present_mode(&caps.present_modes) {
|
||||||
|
Some(mode) => self.config.present_mode = mode,
|
||||||
|
None => {
|
||||||
|
if !self.presentation_warned {
|
||||||
|
eprintln!(
|
||||||
|
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
||||||
|
NO_USABLE_GPU_MESSAGE
|
||||||
|
);
|
||||||
|
self.presentation_warned = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.recalculate_viewport();
|
||||||
self.viewport = PageViewport::calculate(
|
|
||||||
new_size.width as f32,
|
|
||||||
new_size.height as f32,
|
|
||||||
Self::VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uniforms();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,6 +913,16 @@ impl CanvasState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_choose_present_mode() {
|
||||||
|
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||||
|
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
|
||||||
|
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
|
||||||
|
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
|
||||||
|
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
|
||||||
|
assert_eq!(choose_present_mode(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pixel_to_ndc_mapping() {
|
fn test_pixel_to_ndc_mapping() {
|
||||||
let width = 800.0;
|
let width = 800.0;
|
||||||
|
|||||||
+35
-2
@@ -772,7 +772,21 @@ pub struct PageViewport {
|
|||||||
impl PageViewport {
|
impl PageViewport {
|
||||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||||
|
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||||
|
pub fn calculate_with_insets(
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
margin: f32,
|
||||||
|
inset_left: f32,
|
||||||
|
inset_right: f32,
|
||||||
|
) -> Self {
|
||||||
|
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||||
|
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||||
|
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||||
|
|
||||||
let window_ratio = avail_w / avail_h;
|
let window_ratio = avail_w / avail_h;
|
||||||
@@ -790,7 +804,7 @@ impl PageViewport {
|
|||||||
(w, h)
|
(w, h)
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_x = (window_width - page_w) / 2.0;
|
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||||
let page_y = (window_height - page_h) / 2.0;
|
let page_y = (window_height - page_h) / 2.0;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -931,6 +945,25 @@ mod tests {
|
|||||||
assert_eq!(nb.current_page_index, 0);
|
assert_eq!(nb.current_page_index, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewport_insets_center_page_in_free_area() {
|
||||||
|
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||||
|
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||||
|
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||||
|
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||||
|
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||||
|
|
||||||
|
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||||
|
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||||
|
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||||
|
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||||
|
|
||||||
|
// Leiste rechts
|
||||||
|
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||||
|
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_viewport_projection() {
|
fn test_page_viewport_projection() {
|
||||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
|||||||
+121
-65
@@ -329,6 +329,59 @@ fn render_recognized_shape(
|
|||||||
layer.add_polygon(poly);
|
layer.add_polygon(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecognizedShape::Ellipse {
|
||||||
|
center,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
rotation,
|
||||||
|
} => {
|
||||||
|
let ring = (0..72)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||||
|
let p =
|
||||||
|
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
|
||||||
|
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(x), Mm(y)), false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
layer.add_polygon(Polygon {
|
||||||
|
rings: vec![ring],
|
||||||
|
mode: PaintMode::Stroke,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RecognizedShape::Arrow { start, end, double } => {
|
||||||
|
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
|
||||||
|
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||||
|
let shaft_len = (*end - *start).length();
|
||||||
|
let dir = (*end - *start).normalize_or_zero();
|
||||||
|
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||||
|
let start_head =
|
||||||
|
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||||
|
let line_start = if *double { start_head.0 } else { *start };
|
||||||
|
let to_pdf = |p: glam::Vec2| {
|
||||||
|
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(x), Mm(y)), false)
|
||||||
|
};
|
||||||
|
layer.add_line(Line {
|
||||||
|
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
|
||||||
|
is_closed: false,
|
||||||
|
});
|
||||||
|
layer.set_fill_color(stroke_color);
|
||||||
|
let mut heads = vec![(*end, end_head)];
|
||||||
|
if *double {
|
||||||
|
heads.push((*start, start_head));
|
||||||
|
}
|
||||||
|
for (tip, (_, left, right)) in heads {
|
||||||
|
layer.add_polygon(Polygon {
|
||||||
|
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
|
||||||
|
mode: PaintMode::Fill,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
RecognizedShape::Rectangle { corners } => {
|
RecognizedShape::Rectangle { corners } => {
|
||||||
let mut ring = Vec::with_capacity(4);
|
let mut ring = Vec::with_capacity(4);
|
||||||
for c in corners {
|
for c in corners {
|
||||||
@@ -412,76 +465,58 @@ fn render_recognized_shape(
|
|||||||
y_axis_end,
|
y_axis_end,
|
||||||
with_arrows,
|
with_arrows,
|
||||||
} => {
|
} => {
|
||||||
|
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||||
|
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||||
|
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||||
|
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||||
|
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||||
|
let y_arrow = crate::geometry::arrow_head(
|
||||||
|
*y_axis_end,
|
||||||
|
y_dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(*y_axis_end - *origin).length(),
|
||||||
|
);
|
||||||
|
let x_arrow = crate::geometry::arrow_head(
|
||||||
|
*x_axis_end,
|
||||||
|
x_dir,
|
||||||
|
PDF_SHAPE_HALF_WIDTH,
|
||||||
|
(*x_axis_end - *origin).length(),
|
||||||
|
);
|
||||||
|
let (y_line_end, x_line_end) = if *with_arrows {
|
||||||
|
(y_arrow.0, x_arrow.0)
|
||||||
|
} else {
|
||||||
|
(*y_axis_end, *x_axis_end)
|
||||||
|
};
|
||||||
|
|
||||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||||
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
for end in [y_line_end, x_line_end] {
|
||||||
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||||
|
layer.add_line(Line {
|
||||||
// Achse 1 (Y)
|
points: vec![
|
||||||
let line_y = Line {
|
(Point::new(Mm(ox), Mm(oy)), false),
|
||||||
points: vec![
|
(Point::new(Mm(ex), Mm(ey)), false),
|
||||||
(Point::new(Mm(ox), Mm(oy)), false),
|
],
|
||||||
(Point::new(Mm(yx), Mm(yy)), false),
|
is_closed: false,
|
||||||
],
|
});
|
||||||
is_closed: false,
|
}
|
||||||
};
|
|
||||||
layer.add_line(line_y);
|
|
||||||
|
|
||||||
// Achse 2 (X)
|
|
||||||
let line_x = Line {
|
|
||||||
points: vec![
|
|
||||||
(Point::new(Mm(ox), Mm(oy)), false),
|
|
||||||
(Point::new(Mm(xx), Mm(xy)), false),
|
|
||||||
],
|
|
||||||
is_closed: false,
|
|
||||||
};
|
|
||||||
layer.add_line(line_x);
|
|
||||||
|
|
||||||
// Pfeilspitzen als gefüllte Dreiecke
|
// Pfeilspitzen als gefüllte Dreiecke
|
||||||
if *with_arrows {
|
if *with_arrows {
|
||||||
layer.set_fill_color(stroke_color);
|
layer.set_fill_color(stroke_color);
|
||||||
|
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
|
||||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
let ring = [tip, left, right]
|
||||||
let arrow_len_page = 25.0;
|
.iter()
|
||||||
let arrow_w_page = 16.0;
|
.map(|p| {
|
||||||
|
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||||
let base_y = *y_axis_end - y_dir * arrow_len_page;
|
(Point::new(Mm(px), Mm(py)), false)
|
||||||
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
})
|
||||||
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
.collect();
|
||||||
|
layer.add_polygon(Polygon {
|
||||||
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
rings: vec![ring],
|
||||||
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
|
mode: PaintMode::Fill,
|
||||||
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
let arrow_poly_y = Polygon {
|
}
|
||||||
rings: vec![vec![
|
|
||||||
(Point::new(Mm(tx), Mm(ty)), false),
|
|
||||||
(Point::new(Mm(lx), Mm(ly)), false),
|
|
||||||
(Point::new(Mm(rx), Mm(ry)), false),
|
|
||||||
]],
|
|
||||||
mode: PaintMode::Fill,
|
|
||||||
winding_order: WindingOrder::NonZero,
|
|
||||||
};
|
|
||||||
layer.add_polygon(arrow_poly_y);
|
|
||||||
|
|
||||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
|
||||||
let base_x = *x_axis_end - x_dir * arrow_len_page;
|
|
||||||
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
|
||||||
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
|
||||||
|
|
||||||
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
|
||||||
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
|
|
||||||
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
|
|
||||||
|
|
||||||
let arrow_poly_x = Polygon {
|
|
||||||
rings: vec![vec![
|
|
||||||
(Point::new(Mm(tx_x), Mm(ty_x)), false),
|
|
||||||
(Point::new(Mm(lx_x), Mm(ly_x)), false),
|
|
||||||
(Point::new(Mm(rx_x), Mm(ry_x)), false),
|
|
||||||
]],
|
|
||||||
mode: PaintMode::Fill,
|
|
||||||
winding_order: WindingOrder::NonZero,
|
|
||||||
};
|
|
||||||
layer.add_polygon(arrow_poly_x);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,6 +873,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
page.add_stroke(coords_stroke);
|
page.add_stroke(coords_stroke);
|
||||||
|
|
||||||
|
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
|
||||||
|
crate::geometry::RecognizedShape::Ellipse {
|
||||||
|
center: glam::Vec2::new(1400.0, 600.0),
|
||||||
|
radius_x: 300.0,
|
||||||
|
radius_y: 150.0,
|
||||||
|
rotation: 0.4,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
page.add_stroke(ellipse_stroke);
|
||||||
|
|
||||||
|
for (id, double) in [(41, false), (42, true)] {
|
||||||
|
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
|
||||||
|
crate::geometry::RecognizedShape::Arrow {
|
||||||
|
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
|
||||||
|
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
|
||||||
|
double,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
page.add_stroke(arrow_stroke);
|
||||||
|
}
|
||||||
|
|
||||||
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
|
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
|
||||||
crate::geometry::RecognizedShape::Triangle {
|
crate::geometry::RecognizedShape::Triangle {
|
||||||
corners: [
|
corners: [
|
||||||
|
|||||||
+1373
-145
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@ pub mod export;
|
|||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod platform;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
+172
-50
@@ -9,8 +9,9 @@ use winit::{
|
|||||||
event::{
|
event::{
|
||||||
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
||||||
},
|
},
|
||||||
event_loop::{ControlFlow, EventLoop},
|
event_loop::{ControlFlow, EventLoopBuilder},
|
||||||
keyboard::{Key, ModifiersState, NamedKey},
|
keyboard::{Key, ModifiersState, NamedKey},
|
||||||
|
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
|
||||||
window::{Window, WindowBuilder},
|
window::{Window, WindowBuilder},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,9 +21,11 @@ use ink_notes::canvas::CanvasState;
|
|||||||
use ink_notes::document::{Notebook, PageBackground};
|
use ink_notes::document::{Notebook, PageBackground};
|
||||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
use ink_notes::geometry::{self, RecognizedShape};
|
||||||
|
use ink_notes::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
};
|
};
|
||||||
|
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||||
use ink_notes::storage::{self, NotebookManifest};
|
use ink_notes::storage::{self, NotebookManifest};
|
||||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||||
use ink_notes::updater::UpdaterController;
|
use ink_notes::updater::UpdaterController;
|
||||||
@@ -136,7 +139,20 @@ fn commit_stroke_to_notebook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let event_loop = EventLoop::new()?;
|
// 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() {
|
||||||
|
DisplayBackend::X11 => {
|
||||||
|
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||||
|
event_loop_builder.with_x11();
|
||||||
|
}
|
||||||
|
DisplayBackend::Wayland => {
|
||||||
|
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||||
|
event_loop_builder.with_wayland();
|
||||||
|
}
|
||||||
|
DisplayBackend::Automatic => {}
|
||||||
|
}
|
||||||
|
let event_loop = event_loop_builder.build()?;
|
||||||
let window = Arc::new(
|
let window = Arc::new(
|
||||||
WindowBuilder::new()
|
WindowBuilder::new()
|
||||||
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
|
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
|
||||||
@@ -144,7 +160,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.build(&event_loop)?,
|
.build(&event_loop)?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?;
|
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
|
||||||
|
Ok(canvas) => canvas,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[Grafik-Fehler] {err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
|
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
|
||||||
Ok(mgr) => {
|
Ok(mgr) => {
|
||||||
@@ -230,6 +252,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut ui_state = UiState::new();
|
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.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
@@ -247,7 +276,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -275,6 +304,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
let mut pinch_prev_distance: Option<f32> = None;
|
let mut pinch_prev_distance: Option<f32> = None;
|
||||||
|
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
||||||
|
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
@@ -287,6 +318,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||||
let mut stylus_barrel_pressed = false;
|
let mut stylus_barrel_pressed = false;
|
||||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
|
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||||
|
let mut stylus_ui_capture = false;
|
||||||
|
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
|
||||||
|
let mut egui_repaint_at: Option<Instant> = None;
|
||||||
|
|
||||||
// Viewport-Pan & Zoom-Zustand
|
// Viewport-Pan & Zoom-Zustand
|
||||||
let mut is_panning = false;
|
let mut is_panning = false;
|
||||||
@@ -379,6 +414,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
octotablet::events::ToolEvent::Out
|
octotablet::events::ToolEvent::Out
|
||||||
| octotablet::events::ToolEvent::Removed => {
|
| octotablet::events::ToolEvent::Removed => {
|
||||||
is_stylus_in_proximity = false;
|
is_stylus_in_proximity = false;
|
||||||
|
if stylus_ui_capture {
|
||||||
|
stylus_ui_capture = false;
|
||||||
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
let input = egui_winit_state.egui_input_mut();
|
||||||
|
input.events.push(egui::Event::PointerButton {
|
||||||
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
});
|
||||||
|
input.events.push(egui::Event::PointerGone);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
if stylus_tool_id == Some(tool.id()) {
|
if stylus_tool_id == Some(tool.id()) {
|
||||||
stylus_tool_id = None;
|
stylus_tool_id = None;
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
@@ -426,6 +474,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
current_pressure = p;
|
current_pressure = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||||
|
// solange nicht auf dem Blatt gezeichnet oder radiert wird
|
||||||
|
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
|
||||||
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|
egui_winit_state
|
||||||
|
.egui_input_mut()
|
||||||
|
.events
|
||||||
|
.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|
if stylus_ui_capture
|
||||||
|
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
|
{
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stylus_ui_capture {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if is_erasing {
|
if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
@@ -472,28 +538,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
stylus_tool_id = Some(tool.id());
|
stylus_tool_id = Some(tool.id());
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
|| (ui_state.show_page_jump_popup
|
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||||
&& egui_ctx.is_pointer_over_area())
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|| (ui_state.is_document_menu_open
|
let input = egui_winit_state.egui_input_mut();
|
||||||
&& egui_ctx.is_pointer_over_area())
|
input.events.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|| (ui_state.is_update_dialog_open
|
input.events.push(egui::Event::PointerButton {
|
||||||
&& egui_ctx.is_pointer_over_area());
|
pos: ui_pos,
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
if is_stylus_in_ui {
|
pressed: true,
|
||||||
egui_ctx.input_mut(|i| {
|
modifiers: egui::Modifiers::default(),
|
||||||
i.events.push(egui::Event::PointerMoved(egui::pos2(
|
|
||||||
sx / scale,
|
|
||||||
sy / scale,
|
|
||||||
)));
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
|
||||||
button: egui::PointerButton::Primary,
|
|
||||||
pressed: true,
|
|
||||||
modifiers: egui::Modifiers::default(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
stylus_ui_capture = true;
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -546,24 +603,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Up => {
|
octotablet::events::ToolEvent::Up => {
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if stylus_ui_capture {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
stylus_ui_capture = false;
|
||||||
|| (ui_state.show_page_jump_popup
|
egui_winit_state.egui_input_mut().events.push(
|
||||||
&& egui_ctx.is_pointer_over_area())
|
egui::Event::PointerButton {
|
||||||
|| (ui_state.is_document_menu_open
|
|
||||||
&& egui_ctx.is_pointer_over_area())
|
|
||||||
|| (ui_state.is_update_dialog_open
|
|
||||||
&& egui_ctx.is_pointer_over_area());
|
|
||||||
|
|
||||||
if is_stylus_in_ui {
|
|
||||||
egui_ctx.input_mut(|i| {
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
button: egui::PointerButton::Primary,
|
button: egui::PointerButton::Primary,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
modifiers: egui::Modifiers::default(),
|
modifiers: egui::Modifiers::default(),
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -633,6 +682,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
|
if let Some(at) = egui_repaint_at {
|
||||||
|
if Instant::now() >= at {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
||||||
if new_status == AutosaveStatus::Pending
|
if new_status == AutosaveStatus::Pending
|
||||||
|| new_status == AutosaveStatus::Saving
|
|| new_status == AutosaveStatus::Saving
|
||||||
@@ -642,6 +699,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
|
} else if let Some(at) = egui_repaint_at {
|
||||||
|
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||||
} else {
|
} else {
|
||||||
target.set_control_flow(ControlFlow::Wait);
|
target.set_control_flow(ControlFlow::Wait);
|
||||||
}
|
}
|
||||||
@@ -657,9 +716,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
||||||
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
||||||
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
||||||
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1)
|
|| ui::is_screen_point_in_ui(
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&egui_ctx,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|
&ui_state,
|
||||||
|
cursor_screen_pos,
|
||||||
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
@@ -686,6 +748,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_erasing = false;
|
is_erasing = false;
|
||||||
is_mouse_drawing = false;
|
is_mouse_drawing = false;
|
||||||
active_touches.clear();
|
active_touches.clear();
|
||||||
|
ui_touch_ids.clear();
|
||||||
|
stylus_ui_capture = false;
|
||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
pinch_prev_distance = None;
|
pinch_prev_distance = None;
|
||||||
pinch_prev_center = None;
|
pinch_prev_center = None;
|
||||||
@@ -1137,7 +1201,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Objektauswahl & Transformation im Select-Modus
|
// 2. Objektauswahl & Transformation im Select-Modus
|
||||||
if ui_state.active_tool == ActiveTool::Select {
|
if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(sx, sy);
|
let screen_vec = Vec2::new(sx, sy);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
@@ -1469,15 +1533,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
|
||||||
|
if ui_touch_ids.contains(&touch.id) {
|
||||||
|
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
|
||||||
|
ui_touch_ids.remove(&touch.id);
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match touch.phase {
|
match touch.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
// Event-Isolation für Touch-Eingaben
|
// Event-Isolation für Touch-Eingaben
|
||||||
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||||
|| ui_state.is_point_in_ui(tx, ty)
|
&egui_ctx,
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&ui_state,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|
(tx, ty),
|
||||||
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
if is_touch_in_ui {
|
if is_touch_in_ui {
|
||||||
|
ui_touch_ids.insert(touch.id);
|
||||||
|
window.request_redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,7 +1601,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
} else if ui_state.active_tool == ActiveTool::Select {
|
} else if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(tx, ty);
|
let screen_vec = Vec2::new(tx, ty);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
@@ -1807,6 +1883,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
updater.update();
|
updater.update();
|
||||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||||
|
|
||||||
|
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||||
|
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||||
|
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
||||||
|
SidebarSide::Left => (reserved, 0.0),
|
||||||
|
SidebarSide::Right => (0.0, reserved),
|
||||||
|
};
|
||||||
|
if canvas.set_ui_insets(inset_left, inset_right) {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// 4. UI-Aktionen verarbeiten
|
// 4. UI-Aktionen verarbeiten
|
||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
@@ -2003,6 +2089,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::LoadAllPages => {
|
||||||
|
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
|
||||||
|
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
|
||||||
|
for idx in 0..notebook.pages.len() {
|
||||||
|
if !notebook.pages[idx].is_loaded {
|
||||||
|
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::GoToPage(idx) => {
|
UiAction::GoToPage(idx) => {
|
||||||
if idx < notebook.pages.len() {
|
if idx < notebook.pages.len() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
@@ -2053,7 +2149,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
_notebook_lock = None;
|
_notebook_lock = None;
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||||
@@ -2312,6 +2408,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SetSidebarSide(side) => {
|
||||||
|
ui_settings.sidebar_side = side;
|
||||||
|
if let Some(path) = ui_settings_path.as_deref() {
|
||||||
|
if let Err(err) = ui_settings.save_to(path) {
|
||||||
|
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||||
|
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::ForceExit => {
|
UiAction::ForceExit => {
|
||||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||||
target.exit();
|
target.exit();
|
||||||
@@ -2323,6 +2429,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let full_output = egui_ctx.end_frame();
|
let full_output = egui_ctx.end_frame();
|
||||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||||
|
|
||||||
|
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
|
||||||
|
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
||||||
|
let repaint_delay = full_output
|
||||||
|
.viewport_output
|
||||||
|
.get(&egui::ViewportId::ROOT)
|
||||||
|
.map(|v| v.repaint_delay)
|
||||||
|
.unwrap_or(Duration::MAX);
|
||||||
|
if repaint_delay.is_zero() {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
} else if repaint_delay < Duration::from_secs(10) {
|
||||||
|
egui_repaint_at = Some(Instant::now() + repaint_delay);
|
||||||
|
} else {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
||||||
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
||||||
size_in_pixels: [canvas.size.width, canvas.size.height],
|
size_in_pixels: [canvas.size.width, canvas.size.height],
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||||
|
//!
|
||||||
|
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||||
|
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||||
|
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||||
|
//! übersteuert die Wahl.
|
||||||
|
|
||||||
|
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DisplayBackend {
|
||||||
|
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||||
|
Automatic,
|
||||||
|
X11,
|
||||||
|
Wayland,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wählt das Anzeigesystem.
|
||||||
|
///
|
||||||
|
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||||
|
/// - `is_wsl`: läuft unter WSL
|
||||||
|
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||||
|
pub fn choose_display_backend(
|
||||||
|
override_value: Option<&str>,
|
||||||
|
is_wsl: bool,
|
||||||
|
has_x11_display: bool,
|
||||||
|
) -> DisplayBackend {
|
||||||
|
match override_value
|
||||||
|
.map(|v| v.trim().to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("x11") => return DisplayBackend::X11,
|
||||||
|
Some("wayland") => return DisplayBackend::Wayland,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if is_wsl && has_x11_display {
|
||||||
|
DisplayBackend::X11
|
||||||
|
} else {
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||||
|
pub fn is_wsl() -> bool {
|
||||||
|
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||||
|
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wahl anhand der aktuellen Umgebung.
|
||||||
|
pub fn display_backend_from_env() -> DisplayBackend {
|
||||||
|
choose_display_backend(
|
||||||
|
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||||
|
is_wsl(),
|
||||||
|
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_choose_display_backend() {
|
||||||
|
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, true, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
// WSL ohne X11-Display: winit entscheiden lassen
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, true, false),
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
);
|
||||||
|
// Natives Linux: winit entscheiden lassen
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, false, true),
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
);
|
||||||
|
// Übersteuerung
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some("wayland"), true, true),
|
||||||
|
DisplayBackend::Wayland
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some(" X11 "), false, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some("quatsch"), true, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-12
@@ -115,12 +115,15 @@ impl SelectionState {
|
|||||||
Page::selection_aabb(&strokes)
|
Page::selection_aabb(&strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||||
|
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||||
|
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||||
pub fn hit_test_handles(
|
pub fn hit_test_handles(
|
||||||
&self,
|
&self,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
canvas: &CanvasState,
|
canvas: &CanvasState,
|
||||||
screen_pos: Vec2,
|
screen_pos: Vec2,
|
||||||
|
pixels_per_point: f32,
|
||||||
) -> SelectionHandleHit {
|
) -> SelectionHandleHit {
|
||||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||||
return SelectionHandleHit::None;
|
return SelectionHandleHit::None;
|
||||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
|||||||
let screen_center = (screen_min + screen_max) * 0.5;
|
let screen_center = (screen_min + screen_max) * 0.5;
|
||||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||||
|
|
||||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
let ppp = pixels_per_point;
|
||||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||||
|
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::RotateHandle {
|
return SelectionHandleHit::RotateHandle {
|
||||||
center_page: page_center,
|
center_page: page_center,
|
||||||
};
|
};
|
||||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::CornerHandle {
|
return SelectionHandleHit::CornerHandle {
|
||||||
corner_idx: i,
|
corner_idx: i,
|
||||||
fixed_corner_page: fixed_corners_page[i],
|
fixed_corner_page: fixed_corners_page[i],
|
||||||
@@ -273,6 +277,16 @@ impl SelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||||
|
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||||
|
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||||
|
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||||
|
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||||
|
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||||
|
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_selection_overlay(
|
pub fn render_selection_overlay(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
selection: &SelectionState,
|
selection: &SelectionState,
|
||||||
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
|
|||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("selection_overlay_layer"),
|
egui::Id::new("selection_overlay_layer"),
|
||||||
));
|
));
|
||||||
|
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||||
|
let ppp = ctx.pixels_per_point();
|
||||||
|
|
||||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||||
if let DragMode::BoxSelect {
|
if let DragMode::BoxSelect {
|
||||||
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
|
|||||||
current_page,
|
current_page,
|
||||||
} = selection.drag_mode
|
} = selection.drag_mode
|
||||||
{
|
{
|
||||||
let p1 = canvas.page_to_screen(start_page);
|
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||||
let p2 = canvas.page_to_screen(current_page);
|
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||||
let min_x = p1[0].min(p2[0]);
|
let min_x = p1[0].min(p2[0]);
|
||||||
let min_y = p1[1].min(p2[1]);
|
let min_y = p1[1].min(p2[1]);
|
||||||
let max_x = p1[0].max(p2[0]);
|
let max_x = p1[0].max(p2[0]);
|
||||||
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
|
|||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||||
);
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Stroke::new(
|
egui::Stroke::new(
|
||||||
1.5_f32,
|
1.5_f32,
|
||||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let p_min = canvas.page_to_screen(aabb.min);
|
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||||
let p_max = canvas.page_to_screen(aabb.max);
|
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||||
|
|
||||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||||
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
// Dreh-Stängel (Stalk) und Drehgriff
|
// Dreh-Stängel (Stalk) und Drehgriff
|
||||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[top_center, rot_handle],
|
[top_center, rot_handle],
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||||
|
//!
|
||||||
|
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::{self, StorageError};
|
||||||
|
|
||||||
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum SidebarSide {
|
||||||
|
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||||
|
#[default]
|
||||||
|
Left,
|
||||||
|
/// Rechts (für Linkshänder)
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UiSettings {
|
||||||
|
pub sidebar_side: SidebarSide,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiSettings {
|
||||||
|
/// Standardpfad gemäß XDG-Spezifikation.
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||||
|
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("ink-notes").join("ui.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
|
pub fn load_from(path: &Path) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert die Einstellungen atomar.
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||||
|
storage::atomic_write_json(path, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_round_trip_and_fallbacks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ink-notes").join("ui.json");
|
||||||
|
|
||||||
|
// Fehlende Datei -> Standard (links)
|
||||||
|
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||||
|
|
||||||
|
let settings = UiSettings {
|
||||||
|
sidebar_side: SidebarSide::Right,
|
||||||
|
};
|
||||||
|
settings.save_to(&path).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|
||||||
|
// Beschädigte Datei -> Standard statt Absturz
|
||||||
|
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
|
||||||
|
// Unbekannte / fehlende Felder werden toleriert
|
||||||
|
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-2
@@ -124,8 +124,12 @@ impl StrokeData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
|
/// Aktuelle Dateiformat-Version von ink-notes.
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 2;
|
/// - Version 2: Stroke-Treue mit tool, width, finalized
|
||||||
|
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
|
||||||
|
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
|
||||||
|
/// sauber als „neuere Version“ ab.
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -697,6 +701,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
|||||||
);
|
);
|
||||||
notebook.load_warnings = load_warnings;
|
notebook.load_warnings = load_warnings;
|
||||||
|
|
||||||
|
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
|
||||||
|
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
|
||||||
|
let current = notebook.current_page_index;
|
||||||
|
if !notebook.pages[current].is_loaded {
|
||||||
|
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
|
||||||
|
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok((notebook, manifest))
|
Ok((notebook, manifest))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2167,4 +2180,106 @@ mod tests {
|
|||||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
||||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_on_later_page_keeps_its_content() {
|
||||||
|
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
|
||||||
|
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
notebook.current_page_index = 1;
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
|
||||||
|
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(loaded.current_page_index, 1);
|
||||||
|
assert!(
|
||||||
|
loaded.current_page().is_loaded,
|
||||||
|
"aktuelle Seite muss geladen sein"
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.current_page().strokes.len(), 1);
|
||||||
|
|
||||||
|
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
|
||||||
|
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
|
||||||
|
loaded.current_page_mut().strokes.push(extra);
|
||||||
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
autosave.mark_dirty(&loaded, &mut loaded_manifest);
|
||||||
|
autosave.flush().unwrap();
|
||||||
|
autosave.shutdown().unwrap();
|
||||||
|
|
||||||
|
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(reloaded.current_page().strokes.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autosave_never_writes_unloaded_placeholder() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
|
||||||
|
let before = fs::read(&page_file).unwrap();
|
||||||
|
|
||||||
|
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
|
||||||
|
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
autosave.mark_page_dirty(&placeholder, &manifest);
|
||||||
|
autosave.flush().unwrap();
|
||||||
|
autosave.shutdown().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&page_file).unwrap(),
|
||||||
|
before,
|
||||||
|
"Seitendatei wurde überschrieben"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_shapes_round_trip_through_page_file() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
|
||||||
|
let shapes = [
|
||||||
|
crate::geometry::RecognizedShape::Ellipse {
|
||||||
|
center: glam::Vec2::new(500.0, 600.0),
|
||||||
|
radius_x: 250.0,
|
||||||
|
radius_y: 120.0,
|
||||||
|
rotation: 0.3,
|
||||||
|
},
|
||||||
|
crate::geometry::RecognizedShape::Arrow {
|
||||||
|
start: glam::Vec2::new(100.0, 100.0),
|
||||||
|
end: glam::Vec2::new(700.0, 300.0),
|
||||||
|
double: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (i, shape) in shapes.iter().enumerate() {
|
||||||
|
notebook
|
||||||
|
.current_page_mut()
|
||||||
|
.strokes
|
||||||
|
.push(crate::geometry::shape_to_stroke(
|
||||||
|
shape.clone(),
|
||||||
|
i as u64 + 1,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
5.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
load_manifest(&dir).unwrap().format_version,
|
||||||
|
CURRENT_FORMAT_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
let loaded: Vec<_> = reloaded
|
||||||
|
.current_page()
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.shape.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user