feat: virtuelles Lineal

- Lineal ein-/ausblenden über die Werkzeugleiste oder Taste L
  (26 cm, mm-/cm-Skala an beiden Kanten, Winkelanzeige)
- Fläche ziehen verschiebt, Griff ⟳ dreht; 15°-Schritte rasten ein
- Striche, die bis 6 mm neben einer Längskante beginnen, folgen bis zum
  Abheben exakt der Kante (Stift, Textmarker, Formen); die Tinte liegt
  um die halbe Strichbreite neben dem Lineal
- Lineal bleibt beim Blättern an seiner Stelle; nur seine Fläche und
  der Griff nehmen Eingaben an, an den Kanten wird gezeichnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:46:08 +02:00
co-authored by Claude Opus 5.5
parent 587632b173
commit 7e05b33ee3
5 changed files with 627 additions and 0 deletions
+28
View File
@@ -168,6 +168,11 @@ pub struct CanvasState {
// Eingefügte Bilder der aktuellen Seite // Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer, image_layer: crate::image_layer::ImageLayer,
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
pub ruler: Option<crate::ruler::Ruler>,
/// Kante, an der der aktuelle Live-Strich eingerastet ist
ruler_lock: Option<crate::ruler::EdgeLock>,
// egui UI-Renderer // egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer, pub egui_renderer: egui_wgpu::Renderer,
} }
@@ -461,6 +466,8 @@ impl CanvasState {
committed_vertex_count: 0, committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128), committed_strokes: Vec::with_capacity(128),
image_layer, image_layer,
ruler: None,
ruler_lock: None,
egui_renderer, egui_renderer,
camera: crate::viewport::ViewportState::new(), camera: crate::viewport::ViewportState::new(),
}) })
@@ -726,6 +733,13 @@ impl CanvasState {
self.live_vertices.clear(); self.live_vertices.clear();
self.live_vertex_count = 0; self.live_vertex_count = 0;
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
self.ruler_lock = self
.ruler
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
// 1. Zwingend durch den 1€-Filter leiten // 1. Zwingend durch den 1€-Filter leiten
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let pt = ControlPoint::new(fx, fy, fp); let pt = ControlPoint::new(fx, fy, fp);
@@ -749,6 +763,7 @@ impl CanvasState {
} }
// 1. Zwingend durch den 1€-Filter leiten // 1. Zwingend durch den 1€-Filter leiten
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let new_pt = ControlPoint::new(fx, fy, fp); let new_pt = ControlPoint::new(fx, fy, fp);
@@ -771,6 +786,19 @@ impl CanvasState {
self.update_live_mesh(); self.update_live_mesh();
} }
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
match self.ruler_lock {
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
None => (x, y),
}
}
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
pub fn is_ruler_locked(&self) -> bool {
self.ruler_lock.is_some()
}
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück. /// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
pub fn cancel_stroke(&mut self) { pub fn cancel_stroke(&mut self) {
self.live_samples.clear(); self.live_samples.clear();
+1
View File
@@ -15,6 +15,7 @@ pub mod page_export;
pub mod pdf_doc; pub mod pdf_doc;
pub mod pdf_merge; pub mod pdf_merge;
pub mod platform; pub mod platform;
pub mod ruler;
pub mod selection; pub mod selection;
pub mod settings; pub mod settings;
pub mod spline; pub mod spline;
+19
View File
@@ -309,6 +309,16 @@ fn texture_for(
} }
} }
/// Abbildung Seite ↔ logische Bildschirmpunkte für die Oberfläche (Lineal).
fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView {
let origin = canvas.page_to_screen(Vec2::ZERO);
let unit = canvas.page_to_screen(Vec2::new(1000.0, 0.0));
ui::PageView {
origin: egui::pos2(origin[0] / scale_factor, origin[1] / scale_factor),
scale: (unit[0] - origin[0]) / 1000.0 / scale_factor,
}
}
fn copy_message(count: usize, as_image: bool) -> String { fn copy_message(count: usize, as_image: bool) -> String {
if as_image { if as_image {
format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme") format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme")
@@ -1246,6 +1256,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
// Lineal ein-/ausblenden: L
Key::Character(c) if c.eq_ignore_ascii_case("l") && !ctrl => {
ui_state.toggle_ruler();
canvas.ruler = ui_state.ruler;
window.request_redraw();
}
// Bild einfügen: Strg + I // Bild einfügen: Strg + I
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
ui_state.open_image_picker(); ui_state.open_image_picker();
@@ -2240,7 +2257,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern // 3. Toolbar & Widgets rendern
updater.update(); updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater); let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater);
canvas.ruler = ui_state.ruler;
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln) // Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32; let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
+239
View File
@@ -0,0 +1,239 @@
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
//!
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
use glam::Vec2;
use crate::document::UNITS_PER_MM;
/// Länge des Lineals (26 cm).
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
/// Breite des Lineals (3,2 cm).
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
/// Radius des Dreh-Griffs.
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
/// Winkel rasten in diesen Schritten ein …
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
/// … wenn sie höchstens so weit davon entfernt sind.
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
/// Lage des Lineals auf dem Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ruler {
/// Mittelpunkt
pub center: Vec2,
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
pub angle: f32,
}
/// Was an einer Stelle des Lineals liegt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RulerHit {
/// Fläche zum Verschieben
Body,
/// Griff zum Drehen
RotateHandle,
}
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeLock {
pub origin: Vec2,
pub dir: Vec2,
}
impl EdgeLock {
/// Fußpunkt von `p` auf der Geraden.
pub fn project(&self, p: Vec2) -> Vec2 {
self.origin + self.dir * (p - self.origin).dot(self.dir)
}
}
impl Ruler {
pub fn new(center: Vec2) -> Self {
Self { center, angle: 0.0 }
}
/// Einheitsvektor entlang der Längsachse.
pub fn axis(&self) -> Vec2 {
Vec2::from_angle(self.angle)
}
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
pub fn normal(&self) -> Vec2 {
self.axis().perp()
}
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
pub fn local(&self, p: Vec2) -> Vec2 {
let d = p - self.center;
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
}
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
pub fn corners(&self) -> [Vec2; 4] {
let a = self.axis() * (RULER_LENGTH / 2.0);
let n = self.normal() * (RULER_WIDTH / 2.0);
[
self.center - a - n,
self.center + a - n,
self.center + a + n,
self.center - a + n,
]
}
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
pub fn handle_center(&self) -> Vec2 {
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
}
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
/// eingerastete Striche.
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
return Some(RulerHit::RotateHandle);
}
let l = self.local(p);
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
}
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
let l = self.local(p);
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
return None;
}
let half = RULER_WIDTH / 2.0;
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
1.0
} else if (l.y + half).abs() <= SNAP_DISTANCE {
-1.0
} else {
return None;
};
Some(EdgeLock {
origin: self.center + self.normal() * side * (half + offset),
dir: self.axis(),
})
}
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
/// 15° rasten ein.
pub fn rotate_toward(&mut self, toward: Vec2) {
let d = toward - self.center;
if d.length_squared() < 1e-6 {
return;
}
self.angle = snap_angle(d.y.atan2(d.x));
}
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
pub fn display_degrees(&self) -> u32 {
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
}
}
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
pub fn snap_angle(angle: f32) -> f32 {
let deg = angle.to_degrees();
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
nearest.to_radians()
} else {
angle
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1e-2
}
#[test]
fn test_geometry_and_hits() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let [tl, tr, br, bl] = ruler.corners();
assert!(close(
tl,
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
));
assert!(close(
br,
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
));
assert!(close(
tr - tl,
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
));
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
assert_eq!(
ruler.hit(ruler.handle_center()),
Some(RulerHit::RotateHandle)
);
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
assert_eq!(
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
None
);
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
}
#[test]
fn test_edge_lock_projects_onto_offset_edge() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
let lock = ruler
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
.unwrap();
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
// Knapp innerhalb der unteren Kante
let lock = ruler
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
.unwrap();
assert!(close(
lock.project(Vec2::new(0.0, 0.0)),
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
));
// Mitte oder weit weg: kein Einrasten
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
assert!(ruler
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
.is_none());
// Gedrehtes Lineal: die Linie folgt der Drehung
let mut tilted = ruler;
tilted.angle = 30f32.to_radians();
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
let a = lock.project(near_edge);
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
let dir = (b - a).normalize();
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
}
#[test]
fn test_rotation_snaps_to_15_degrees() {
let mut ruler = Ruler::new(Vec2::ZERO);
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 45);
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 37);
// Gegenrichtung zeigt denselben Winkel
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 30);
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
}
}
+340
View File
@@ -17,6 +17,7 @@ use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
}; };
use crate::file_picker::{FilePicker, PickerKind}; use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit};
use crate::settings::{PressureCurve, SidebarSide}; use crate::settings::{PressureCurve, SidebarSide};
use crate::storage::NotebookSummary; use crate::storage::NotebookSummary;
@@ -97,6 +98,37 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")]; [(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageView {
/// Bildschirmposition des Seitenursprungs (oben links)
pub origin: egui::Pos2,
/// Bildschirmpunkte je Seiteneinheit
pub scale: f32,
}
impl PageView {
pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 {
self.origin + egui::vec2(p.x, p.y) * self.scale
}
pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 {
let d = (p - self.origin) / self.scale.max(1e-6);
glam::Vec2::new(d.x, d.y)
}
}
/// Laufende Bedienung des Lineals.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RulerDrag {
/// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt
Move {
grab_offset: glam::Vec2,
},
Rotate,
}
/// Format für den Export der aktuellen Seite. /// Format für den Export der aktuellen Seite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageExportKind { pub enum PageExportKind {
@@ -234,6 +266,13 @@ pub struct UiState {
pub last_raw_pressure: Option<f32>, pub last_raw_pressure: Option<f32>,
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“) /// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
pub file_picker: Option<FilePicker>, pub file_picker: Option<FilePicker>,
/// Eingeblendetes Lineal
pub ruler: Option<Ruler>,
pub ruler_drag: Option<RulerDrag>,
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
pub page_view: Option<PageView>,
/// Fenstergröße in logischen Punkten (vom letzten Bild)
pub screen_size: egui::Vec2,
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört /// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
pub page_title_input: String, pub page_title_input: String,
pub page_title_for: Option<u64>, pub page_title_for: Option<u64>,
@@ -286,6 +325,10 @@ impl Default for UiState {
last_raw_pressure: None, last_raw_pressure: None,
file_picker: None, file_picker: None,
queued_actions: Vec::new(), queued_actions: Vec::new(),
ruler: None,
ruler_drag: None,
page_view: None,
screen_size: egui::Vec2::ZERO,
page_title_input: String::new(), page_title_input: String::new(),
page_title_for: None, page_title_for: None,
page_picker_list: false, page_picker_list: false,
@@ -314,6 +357,39 @@ impl UiState {
self.file_picker = None; self.file_picker = None;
} }
/// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus.
pub fn toggle_ruler(&mut self) {
self.ruler_drag = None;
self.ruler = match self.ruler {
Some(_) => None,
None => {
let center = self
.page_view
.map(|v| {
let screen = self.visible_canvas_center();
v.to_page(screen)
})
.unwrap_or(glam::Vec2::new(
crate::document::PAGE_WIDTH / 2.0,
crate::document::PAGE_HEIGHT / 2.0,
));
Some(Ruler::new(center))
}
};
}
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
fn visible_canvas_center(&self) -> egui::Pos2 {
(self.screen_size / 2.0).to_pos2()
+ egui::vec2(
match self.sidebar_side {
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
SidebarSide::Right => -self.sidebar_reserved_width() / 2.0,
},
0.0,
)
}
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner. /// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
pub fn open_image_picker(&mut self) { pub fn open_image_picker(&mut self) {
self.open_file_picker(PickerKind::Image); self.open_file_picker(PickerKind::Image);
@@ -442,6 +518,15 @@ impl UiState {
if self.is_point_inside_toolbar(px, py) { if self.is_point_inside_toolbar(px, py) {
return true; return true;
} }
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
if self.ruler_drag.is_some() {
return true;
}
if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) {
if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() {
return true;
}
}
if let Some(pop_rect) = self.popover_rect { if let Some(pop_rect) = self.popover_rect {
if pop_rect.contains(egui::pos2(px, py)) { if pop_rect.contains(egui::pos2(px, py)) {
return true; return true;
@@ -554,6 +639,8 @@ enum SidebarIcon<'a> {
Eraser, Eraser,
/// Selbst gezeichnetes gestricheltes Auswahlrechteck /// Selbst gezeichnetes gestricheltes Auswahlrechteck
Lasso, Lasso,
/// Selbst gezeichnetes Lineal mit Skala
Ruler,
} }
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante. /// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
@@ -656,6 +743,30 @@ fn sidebar_button(
stroke, stroke,
)); ));
} }
SidebarIcon::Ruler => {
let dir = egui::vec2(1.0, -1.0).normalized();
let perp = egui::vec2(dir.y, -dir.x);
let (half_len, half_w) = (12.0, 4.5);
let p = |along: f32, across: f32| c + dir * along + perp * across;
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(-half_len, half_w),
],
egui::Color32::TRANSPARENT,
egui::Stroke::new(1.5_f32, color),
));
for i in 1..6 {
let along = -half_len + i as f32 * 4.0;
let tick = if i % 2 == 0 { 4.0 } else { 2.5 };
painter.line_segment(
[p(along, -half_w), p(along, -half_w + tick)],
egui::Stroke::new(1.2_f32, color),
);
}
}
SidebarIcon::Lasso => { SidebarIcon::Lasso => {
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0)); let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
let pts = [ let pts = [
@@ -1076,6 +1187,149 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
save.then_some(state.pressure_curve) save.then_some(state.pressure_curve)
} }
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
state.ruler_drag = None;
return;
};
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
let (pressed, down, pos) = ctx.input(|i| {
(
i.pointer.primary_pressed(),
i.pointer.primary_down(),
i.pointer.interact_pos(),
)
});
if pressed && !ctx.is_pointer_over_area() {
if let Some(pos) = pos {
let page = view.to_page(pos);
state.ruler_drag = match ruler.hit(page) {
Some(RulerHit::Body) => Some(RulerDrag::Move {
grab_offset: ruler.center - page,
}),
Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate),
None => None,
};
}
}
match (state.ruler_drag, down, pos) {
(Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => {
ruler.center = view.to_page(pos) + grab_offset;
}
(Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)),
(_, false, _) => state.ruler_drag = None,
_ => {}
}
state.ruler = Some(ruler);
// Zeichnen (unter Leiste und Dialogen)
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("ruler_overlay"),
));
let corners: Vec<egui::Pos2> = ruler.corners().iter().map(|&c| view.to_screen(c)).collect();
painter.add(egui::Shape::convex_polygon(
corners.clone(),
egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200),
),
));
let ink = egui::Color32::from_rgb(45, 52, 66);
let axis = ruler.axis();
let normal = ruler.normal();
let mm = crate::document::UNITS_PER_MM;
let px_per_mm = view.scale * mm;
let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32;
let half_w = crate::ruler::RULER_WIDTH / 2.0;
// Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen
for side in [-1.0_f32, 1.0] {
let edge = ruler.center + normal * side * half_w;
for i in -half_len_mm..=half_len_mm {
let tick_mm = if i % 10 == 0 {
4.0
} else if i % 5 == 0 {
2.5
} else if px_per_mm >= 3.0 {
1.5
} else {
continue;
};
let base = edge + axis * (i as f32 * mm);
let tip = base - normal * side * tick_mm * mm;
painter.line_segment(
[view.to_screen(base), view.to_screen(tip)],
egui::Stroke::new(1.0_f32, ink),
);
}
}
// Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt
if px_per_mm >= 1.2 {
let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0));
let edge = ruler.center - normal * half_w;
for cm in 0..=(half_len_mm * 2 / 10) {
let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm;
let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm);
let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink));
let offset = egui::vec2(galley.size().x / 2.0, 0.0);
let offset = egui::vec2(
offset.x * axis.x - offset.y * axis.y,
offset.x * axis.y + offset.y * axis.x,
);
let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink);
text.angle = ruler.angle;
painter.add(text);
}
}
// Winkelanzeige und Dreh-Griff
let center = view.to_screen(ruler.center);
let label = format!("{}°", ruler.display_degrees());
let galley = ctx.fonts(|f| {
f.layout_no_wrap(
label,
egui::FontId::proportional(13.0),
egui::Color32::WHITE,
)
});
let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0));
painter.rect_filled(
pill,
egui::Rounding::same(9.0),
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220),
);
painter.galley(
pill.center() - galley.size() / 2.0,
galley,
egui::Color32::WHITE,
);
let handle = view.to_screen(ruler.handle_center());
let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0);
let active = state.ruler_drag == Some(RulerDrag::Rotate);
painter.circle(
handle,
radius,
if active {
egui::Color32::from_rgb(70, 120, 210)
} else {
egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200)
},
egui::Stroke::new(1.0_f32, egui::Color32::WHITE),
);
painter.text(
handle,
egui::Align2::CENTER_CENTER,
"⟳",
egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)),
egui::Color32::WHITE,
);
}
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus /// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
/// Ordnern und passenden Dateien. /// Ordnern und passenden Dateien.
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) { fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
@@ -1333,6 +1587,8 @@ pub fn render_ui(
updater: &crate::updater::UpdaterController, updater: &crate::updater::UpdaterController,
) -> Vec<UiAction> { ) -> Vec<UiAction> {
let mut actions = std::mem::take(&mut state.queued_actions); let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size();
ruler_overlay(ctx, state);
let mut pen_btn_rect = None; let mut pen_btn_rect = None;
let mut high_btn_rect = None; let mut high_btn_rect = None;
@@ -1519,6 +1775,22 @@ pub fn render_ui(
} }
} }
// --- Lineal (ein-/ausblenden) ---
if sidebar_button(
ui,
btn,
SidebarIcon::Ruler,
state.ruler.is_some(),
true,
None,
"Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \
Striche an einer Kante werden gerade",
)
.clicked()
{
state.toggle_ruler();
}
// --- Farben des aktiven Werkzeugs --- // --- Farben des aktiven Werkzeugs ---
let palette: Option<(&[ColorPreset], [f32; 4])> = let palette: Option<(&[ColorPreset], [f32; 4])> =
match state.active_tool { match state.active_tool {
@@ -3516,6 +3788,74 @@ mod tests {
out out
} }
#[test]
fn test_ruler_drag_moves_and_rotates() {
let ctx = egui::Context::default();
let notebook = Notebook::new();
let updater = crate::updater::UpdaterController::new();
let mut state = UiState::new();
// 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0)
let view = PageView {
origin: egui::pos2(100.0, 0.0),
scale: 0.5,
};
state.page_view = Some(view);
state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0)));
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1600.0, 1000.0),
)),
events,
..Default::default()
};
ctx.begin_frame(input);
let _ = render_ui(&ctx, state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
};
let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
};
// Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen
let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0));
assert!(state.is_point_in_ui(center.x, center.y));
let edge = view.to_screen(glam::Vec2::new(
1000.0,
1000.0 - crate::ruler::RULER_WIDTH / 2.0,
));
assert!(!state.is_point_in_ui(edge.x, edge.y));
// Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen
frame(&mut state, vec![egui::Event::PointerMoved(center)]);
frame(&mut state, vec![press(center, true)]);
let target = center + egui::vec2(0.0, 50.0);
frame(&mut state, vec![egui::Event::PointerMoved(target)]);
assert!(state.ruler_drag.is_some());
frame(&mut state, vec![press(target, false)]);
let moved = state.ruler.unwrap();
assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5);
assert!(state.ruler_drag.is_none());
// Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet)
let handle = view.to_screen(moved.handle_center());
frame(&mut state, vec![egui::Event::PointerMoved(handle)]);
frame(&mut state, vec![press(handle, true)]);
let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0));
frame(&mut state, vec![egui::Event::PointerMoved(diag)]);
frame(&mut state, vec![press(diag, false)]);
assert_eq!(state.ruler.unwrap().display_degrees(), 45);
// Ausblenden
state.toggle_ruler();
assert!(state.ruler.is_none());
}
#[test] #[test]
fn test_stroke_tool_follows_active_tool() { fn test_stroke_tool_follows_active_tool() {
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen); assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);