Files
ink-notes/src/selection.rs
T
haraldandClaude Opus 5.5 b5c1da07fa feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)
- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:54 +02:00

534 lines
18 KiB
Rust

//! Objekt-Auswahl und Manipulation (Selektion, Verschieben, Drehen, Skalieren, Kopieren, Einfügen, Umfärben).
//!
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
use glam::Vec2;
use std::f32::consts::FRAC_PI_2;
use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke};
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum DragMode {
#[default]
None,
/// Aufziehen eines Auswahlrahmens (Marquee Box)
BoxSelect {
start_page: Vec2,
current_page: Vec2,
},
/// Verschieben ausgewählter Objekte
Move {
start_page: Vec2,
initial_strokes: Vec<Stroke>,
},
/// Drehen ausgewählter Objekte um das Zentrum der Auswahl
Rotate {
center_page: Vec2,
start_angle: f32,
initial_strokes: Vec<Stroke>,
},
/// Skalieren ausgewählter Objekte relativ zum gegenüberliegenden Eckpunkt
Scale {
fixed_corner_page: Vec2,
start_pos_page: Vec2,
initial_box_size: Vec2,
initial_strokes: Vec<Stroke>,
},
}
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit {
None,
/// Dreh-Griff (oberhalb der Auswahlbox)
RotateHandle {
center_page: Vec2,
},
/// Einer der 4 Eck-Skalierungsgriffe (0: oben-links, 1: oben-rechts, 2: unten-rechts, 3: unten-links)
CornerHandle {
corner_idx: usize,
fixed_corner_page: Vec2,
start_pos_page: Vec2,
initial_box_size: Vec2,
},
/// Klick in den Körper / Innenbereich der Auswahlbox
Body,
}
/// Verwaltet den Zustand der Objektauswahl, des Clipboards und der Manipulationen.
#[derive(Debug, Clone, Default)]
pub struct SelectionState {
pub selected_ids: Vec<u64>,
pub drag_mode: DragMode,
pub clipboard: Vec<Stroke>,
}
impl SelectionState {
pub fn new() -> Self {
Self {
selected_ids: Vec::new(),
drag_mode: DragMode::None,
clipboard: Vec::new(),
}
}
/// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty()
}
/// Anzahl der aktuell ausgewählten Objekte.
pub fn count(&self) -> usize {
self.selected_ids.len()
}
/// Leert die aktuelle Auswahl.
pub fn clear(&mut self) {
self.selected_ids.clear();
self.drag_mode = DragMode::None;
}
/// Wählt ein einzelnes Objekt aus.
pub fn select_single(&mut self, id: u64) {
self.selected_ids = vec![id];
}
/// Schaltet die Auswahl eines Objekts um (z. B. bei Shift+Klick).
pub fn toggle_select(&mut self, id: u64) {
if let Some(pos) = self.selected_ids.iter().position(|&x| x == id) {
self.selected_ids.remove(pos);
} else {
self.selected_ids.push(id);
}
}
/// Setzt die Auswahl auf eine Liste von Objekt-IDs.
pub fn select_multiple(&mut self, ids: Vec<u64>) {
self.selected_ids = ids;
}
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids);
Page::selection_aabb(&strokes)
}
/// 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;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let screen_min = Vec2::new(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = Vec2::new(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 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,
};
}
// 2. 4 Eck-Skalierungsgriffe testen
let corners_page = [
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
];
let fixed_corners_page = [
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
];
let corners_screen = [
screen_min,
Vec2::new(screen_max.x, screen_min.y),
screen_max,
Vec2::new(screen_min.x, screen_max.y),
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
start_pos_page: corners_page[i],
initial_box_size: aabb.max - aabb.min,
};
}
}
// 3. Körper-Test
let margin = 6.0;
if screen_pos.x >= screen_min.x - margin
&& screen_pos.x <= screen_max.x + margin
&& screen_pos.y >= screen_min.y - margin
&& screen_pos.y <= screen_max.y + margin
{
return SelectionHandleHit::Body;
}
SelectionHandleHit::None
}
/// Kopiert die aktuell ausgewählten Objekte in das Clipboard.
pub fn copy_to_clipboard(&mut self, page: &Page) -> usize {
let strokes = page.get_strokes(&self.selected_ids);
let count = strokes.len();
self.clipboard = strokes;
count
}
/// Fügt Objekte aus dem Clipboard mit Versatz (+30 Einheiten) ein, vergibt neue IDs und wählt sie aus.
pub fn paste_from_clipboard(
&mut self,
page: &mut Page,
next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> {
if self.clipboard.is_empty() {
return Vec::new();
}
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len());
for stroke in &self.clipboard {
let new_id = next_id_gen();
let mut cloned = stroke.clone();
cloned.id = new_id;
cloned.translate(offset);
new_ids.push(new_id);
new_strokes.push(cloned);
}
page.add_strokes(new_strokes);
self.selected_ids = new_ids.clone();
new_ids
}
/// Löscht die ausgewählten Objekte transaktional aus der Seite.
pub fn delete_selected(&mut self, page: &mut Page) -> Vec<Stroke> {
if self.selected_ids.is_empty() {
return Vec::new();
}
let removed = page.remove_strokes_by_ids(&self.selected_ids);
self.selected_ids.clear();
self.drag_mode = DragMode::None;
removed
}
/// Färbt alle ausgewählten Objekte auf `new_color` um (inkl. Vertex-Buffer).
pub fn recolor_selected(&mut self, page: &mut Page, new_color: [f32; 4]) -> bool {
if self.selected_ids.is_empty() {
return false;
}
// Bilder behalten ihre Farben
let mut strokes: Vec<_> = page
.get_strokes(&self.selected_ids)
.into_iter()
.filter(|s| !s.is_image())
.collect();
if strokes.is_empty() {
return false;
}
for s in &mut strokes {
s.set_color(new_color);
}
page.modify_strokes(strokes);
true
}
/// Dreht alle ausgewählten Objekte um 90° im Uhrzeigersinn um das Zentrum ihrer AABB.
pub fn rotate_selected_90_deg(&mut self, page: &mut Page) -> bool {
let Some(aabb) = self.get_selection_aabb(page) else {
return false;
};
let center = (aabb.min + aabb.max) * 0.5;
let mut strokes = page.get_strokes(&self.selected_ids);
if strokes.is_empty() {
return false;
}
for s in &mut strokes {
s.rotate(center, FRAC_PI_2);
}
page.modify_strokes(strokes);
true
}
}
/// 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,
page: &Page,
canvas: &CanvasState,
) {
let painter = ctx.layer_painter(egui::LayerId::new(
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 {
start_page,
current_page,
} = selection.drag_mode
{
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]);
let max_y = p1[1].max(p2[1]);
let rect = egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y));
// Halbtransparente Füllung & blauer Auswahlrand
painter.rect_filled(
rect,
egui::Rounding::same(4.0_f32),
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_unmultiplied(80, 160, 255, 220),
),
);
return;
}
// 2. Wenn Objekte ausgewählt sind, Bounding Box und Griffe zeichnen
let Some(aabb) = selection.get_selection_aabb(page) else {
return;
};
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]));
let rect = egui::Rect::from_min_max(screen_min, screen_max);
// Haupt-Auswahlrahmen (leuchtend blau)
let border_color = egui::Color32::from_rgb(70, 150, 255);
painter.rect_stroke(
rect,
egui::Rounding::same(3.0_f32),
egui::Stroke::new(1.5_f32, border_color),
);
// 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 - ROTATE_HANDLE_OFFSET);
painter.line_segment(
[top_center, rot_handle],
egui::Stroke::new(1.2_f32, border_color),
);
// Drehgriff-Kreis
painter.circle_filled(rot_handle, 6.0_f32, egui::Color32::WHITE);
painter.circle_stroke(
rot_handle,
6.0_f32,
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(40, 90, 180)),
);
// 4 Eck-Skalierungsgriffe (kleine weiße Quadrate mit blauem Rand)
let handle_size = 7.0_f32;
let corners = [
rect.min,
egui::pos2(rect.max.x, rect.min.y),
rect.max,
egui::pos2(rect.min.x, rect.max.y),
];
for c in corners {
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
painter.rect_filled(
handle_rect,
egui::Rounding::same(1.5_f32),
egui::Color32::WHITE,
);
painter.rect_stroke(
handle_rect,
egui::Rounding::same(1.5_f32),
egui::Stroke::new(1.2_f32, egui::Color32::from_rgb(30, 80, 170)),
);
}
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
let badge_text = format!(
"{} {} | [R] Drehen [Entf] Löschen",
selection.count(),
if selection.count() == 1 {
"Objekt"
} else {
"Objekte"
}
);
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text(
badge_pos,
egui::Align2::CENTER_CENTER,
badge_text,
egui::FontId::proportional(11.0),
egui::Color32::from_gray(220),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canvas::InkVertex;
use crate::document::{PageBackground, StylusSample};
fn make_test_stroke(id: u64, x: f32, y: f32, color: [f32; 4]) -> Stroke {
let s1 = StylusSample {
position: Vec2::new(x, y),
pressure: 0.5,
timestamp: 0.0,
};
let s2 = StylusSample {
position: Vec2::new(x + 50.0, y + 50.0),
pressure: 0.5,
timestamp: 0.01,
};
let v1 = InkVertex::new(x, y, color);
let v2 = InkVertex::new(x + 50.0, y + 50.0, color);
Stroke::new(id, vec![s1, s2], vec![v1, v2], color)
}
#[test]
fn test_selection_copy_paste_delete() {
let mut page = Page::new(1, PageBackground::Blank);
let s1 = make_test_stroke(1, 100.0, 100.0, [0.1, 0.1, 0.1, 1.0]);
let s2 = make_test_stroke(2, 300.0, 300.0, [0.8, 0.2, 0.2, 1.0]);
page.add_stroke(s1);
page.add_stroke(s2);
let mut sel = SelectionState::new();
sel.select_single(1);
assert_eq!(sel.count(), 1);
// Copy
let copied = sel.copy_to_clipboard(&page);
assert_eq!(copied, 1);
// Paste
let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
next_id += 1;
next_id
});
assert_eq!(new_ids.len(), 1);
assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids);
// Delete
let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1);
assert_eq!(page.strokes.len(), 2);
assert!(sel.is_empty());
}
#[test]
fn test_selection_recolor_and_rotate() {
let mut page = Page::new(1, PageBackground::Blank);
let s1 = make_test_stroke(1, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]);
page.add_stroke(s1);
let mut sel = SelectionState::new();
sel.select_single(1);
// Recolor
let new_color = [0.0, 1.0, 0.0, 1.0];
assert!(sel.recolor_selected(&mut page, new_color));
assert_eq!(page.strokes[0].color, new_color);
assert_eq!(page.strokes[0].vertices[0].color, new_color);
// Undo recolor
assert!(page.undo());
assert_eq!(page.strokes[0].color, [0.1, 0.1, 0.1, 1.0]);
// Rotate 90 deg
let orig_pos = page.strokes[0].points[0].position;
assert!(sel.rotate_selected_90_deg(&mut page));
let rotated_pos = page.strokes[0].points[0].position;
assert!(
(orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1
);
// Undo rotate
assert!(page.undo());
let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
}
#[test]
fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
let undo_depth = page.history.undo_stack.len();
let mut sel = SelectionState::new();
sel.select_single(1);
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.history.undo_stack.len(), undo_depth);
// Bild und Strich: nur der Strich wird umgefärbt
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
sel.select_multiple(vec![1, 2]);
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
}
}