Compare commits

...
6 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
haraldandClaude Opus 5.5 b03faedd27 chore(release): bump version to 0.3.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:26:27 +02:00
haraldandClaude Opus 5.5 f17e50ec32 feat(ui): senkrechte Werkzeugleiste und Stift-/Touch-Bedienung der UI
Werkzeugleiste
- Schmale senkrechte Leiste statt ~30 Elemente in einer Zeile; seitlich oben
  angedockt (links, im ☰-Menü auf rechts umschaltbar, gespeichert in
  ~/.config/ink-notes/ui.json)
- Symbol-Knöpfe 40 px, auf niedrigen Fenstern schrittweise bis 28 px; Größe
  richtet sich nach dem höchsten Fall, damit Knöpfe beim Werkzeugwechsel
  nicht springen (fester Platz für die Farbpalette)
- Selten genutztes (PDF, Hintergrund, Zoom, Finger-Zeichnen, Leistenseite,
  Updates, Speicherstatus) im ☰-Menü; Popover öffnen zur Blattseite hin
- Blatt wird im freien Bereich neben der Leiste zentriert
- Symbole ohne Glyphe in den egui-Schriften (🖍 ⌫ ⬚ ◄ ► ● ✕ ▾) ersetzt bzw.
  selbst gezeichnet; Hover-Farbe korrigiert (falsch vormultipliziert)

Eingabe
- UI-Trefferprüfung in logischen Punkten (vorher physische Pixel gegen
  egui-Koordinaten: bei Skalierung ≠ 1 trafen Stift und Touch die Leiste nicht)
  und über egui::Context::layer_id_at für alle egui-Bereiche
- Stift-Ereignisse über egui_input_mut() statt ctx.input_mut() (wurden vom
  nächsten Frame verworfen); Stiftbewegungen für Hover/Ziehen weitergereicht,
  Stift bleibt vom Aufsetzen bis Abheben bei der UI
- Berührungen, die auf der UI beginnen, bleiben bis zum Abheben bei egui
- egui-Neuzeichenwünsche (repaint_delay) werden beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:51 +02:00
10 changed files with 2090 additions and 1182 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.2.3"
version = "0.3.2"
dependencies = [
"bytemuck",
"egui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.2.3"
version = "0.3.2"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+3
View File
@@ -3,6 +3,9 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **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 als Kachelraster, 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
- **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)
+29 -8
View File
@@ -94,6 +94,9 @@ pub struct CanvasState {
pub config: wgpu::SurfaceConfiguration,
pub size: PhysicalSize<u32>,
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,
// Pipelines
@@ -371,6 +374,8 @@ impl CanvasState {
config,
size,
viewport,
ui_inset_left: 0.0,
ui_inset_right: 0.0,
strip_pipeline,
triangle_pipeline,
camera_buffer,
@@ -418,6 +423,29 @@ 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.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
@@ -425,14 +453,7 @@ impl CanvasState {
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
self.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
self.recalculate_viewport();
}
}
+35 -2
View File
@@ -772,7 +772,21 @@ pub struct PageViewport {
impl PageViewport {
/// 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 {
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 window_ratio = avail_w / avail_h;
@@ -790,7 +804,7 @@ impl PageViewport {
(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;
Self {
@@ -931,6 +945,25 @@ mod tests {
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]
fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
+1
View File
@@ -9,6 +9,7 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod selection;
pub mod settings;
pub mod spline;
pub mod storage;
pub mod ui;
+138 -47
View File
@@ -23,6 +23,7 @@ use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
};
use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
@@ -230,6 +231,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
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.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
@@ -247,7 +255,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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("⚠ In anderer Instanz geöffnet (Schreibschutz)");
None
}
Err(e) => {
@@ -275,6 +283,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
std::collections::HashMap::new();
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 is_pinch_gesturing = false;
let mut is_touch_drawing = false;
@@ -287,6 +297,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false;
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
let mut is_panning = false;
@@ -379,6 +393,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
octotablet::events::ToolEvent::Out
| octotablet::events::ToolEvent::Removed => {
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()) {
stylus_tool_id = None;
if is_stylus_drawing {
@@ -426,6 +453,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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 let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
@@ -472,28 +517,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stylus_tool_id = Some(tool.id());
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (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::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(),
});
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
let ui_pos = egui::pos2(sx / scale, sy / scale);
let input = egui_winit_state.egui_input_mut();
input.events.push(egui::Event::PointerMoved(ui_pos));
input.events.push(egui::Event::PointerButton {
pos: ui_pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
stylus_ui_capture = true;
window.request_redraw();
continue;
}
@@ -546,24 +582,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
octotablet::events::ToolEvent::Up => {
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (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 {
if stylus_ui_capture {
stylus_ui_capture = false;
egui_winit_state.egui_input_mut().events.push(
egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
});
},
);
window.request_redraw();
continue;
}
@@ -633,6 +661,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
if new_status == AutosaveStatus::Pending
|| new_status == AutosaveStatus::Saving
@@ -642,6 +678,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| is_stylus_in_proximity
{
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 {
target.set_control_flow(ControlFlow::Wait);
}
@@ -657,9 +695,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
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_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|| ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
cursor_screen_pos,
window.scale_factor() as f32,
);
match event {
WindowEvent::CloseRequested => {
@@ -686,6 +727,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false;
is_mouse_drawing = false;
active_touches.clear();
ui_touch_ids.clear();
stylus_ui_capture = false;
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
@@ -1137,7 +1180,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select {
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 {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1469,15 +1512,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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 {
TouchPhase::Started => {
// Event-Isolation für Touch-Eingaben
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(tx, ty)
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
let is_touch_in_ui = ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
(tx, ty),
window.scale_factor() as f32,
);
if is_touch_in_ui {
ui_touch_ids.insert(touch.id);
window.request_redraw();
return;
}
@@ -1525,7 +1580,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if ui_state.active_tool == ActiveTool::Select {
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 {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -1807,6 +1862,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
updater.update();
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, 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
for action in actions {
match action {
@@ -2053,7 +2118,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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("⚠ In anderer Instanz geöffnet (Schreibschutz)");
}
Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
@@ -2312,6 +2377,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 => {
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
target.exit();
@@ -2323,6 +2398,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let full_output = egui_ctx.end_frame();
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 screen_descriptor = egui_wgpu::ScreenDescriptor {
size_in_pixels: [canvas.size.width, canvas.size.height],
+28 -12
View File
@@ -115,12 +115,15 @@ impl SelectionState {
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(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let ppp = pixels_per_point;
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 {
center_page: page_center,
};
@@ -164,7 +168,7 @@ impl SelectionState {
];
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 {
corner_idx: 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.
/// 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(
ctx: &egui::Context,
selection: &SelectionState,
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground,
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)
if let DragMode::BoxSelect {
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
painter.rect_filled(
rect,
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(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
),
);
return;
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
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_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
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(
[top_center, rot_handle],
+84
View File
@@ -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());
}
}
+1770 -1111
View File
File diff suppressed because it is too large Load Diff