Initial commit: ink-notes (latency-optimized Linux inking app in Rust)
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
//! 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 std::f32::consts::FRAC_PI_2;
|
||||
use glam::Vec2;
|
||||
|
||||
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 durch.
|
||||
pub fn hit_test_handles(
|
||||
&self,
|
||||
page: &Page,
|
||||
canvas: &CanvasState,
|
||||
screen_pos: Vec2,
|
||||
) -> 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 (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 {
|
||||
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() <= 12.0 {
|
||||
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;
|
||||
}
|
||||
let mut strokes = page.get_strokes(&self.selected_ids);
|
||||
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.
|
||||
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"),
|
||||
));
|
||||
|
||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
|
||||
let p1 = canvas.page_to_screen(start_page);
|
||||
let p2 = canvas.page_to_screen(current_page);
|
||||
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_premultiplied(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)),
|
||||
);
|
||||
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 = canvas.page_to_screen(aabb.min);
|
||||
let p_max = canvas.page_to_screen(aabb.max);
|
||||
|
||||
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 - 24.0_f32);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user