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
+340
View File
@@ -17,6 +17,7 @@ use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit};
use crate::settings::{PressureCurve, SidebarSide};
use crate::storage::NotebookSummary;
@@ -97,6 +98,37 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageExportKind {
@@ -234,6 +266,13 @@ pub struct UiState {
pub last_raw_pressure: Option<f32>,
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
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
pub page_title_input: String,
pub page_title_for: Option<u64>,
@@ -286,6 +325,10 @@ impl Default for UiState {
last_raw_pressure: None,
file_picker: None,
queued_actions: Vec::new(),
ruler: None,
ruler_drag: None,
page_view: None,
screen_size: egui::Vec2::ZERO,
page_title_input: String::new(),
page_title_for: None,
page_picker_list: false,
@@ -314,6 +357,39 @@ impl UiState {
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.
pub fn open_image_picker(&mut self) {
self.open_file_picker(PickerKind::Image);
@@ -442,6 +518,15 @@ impl UiState {
if self.is_point_inside_toolbar(px, py) {
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 pop_rect.contains(egui::pos2(px, py)) {
return true;
@@ -554,6 +639,8 @@ enum SidebarIcon<'a> {
Eraser,
/// Selbst gezeichnetes gestricheltes Auswahlrechteck
Lasso,
/// Selbst gezeichnetes Lineal mit Skala
Ruler,
}
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
@@ -656,6 +743,30 @@ fn sidebar_button(
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 => {
let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0));
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)
}
/// 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
/// Ordnern und passenden Dateien.
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,
) -> Vec<UiAction> {
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 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 ---
let palette: Option<(&[ColorPreset], [f32; 4])> =
match state.active_tool {
@@ -3516,6 +3788,74 @@ mod tests {
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]
fn test_stroke_tool_follows_active_tool() {
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);