feat: Text-Werkzeug
Textfelder per Tastatur (T): normale oder Code-Schrift, 8–36 pt, Farben der Stiftpalette, Umbruch am rechten Feldrand. Textfelder sind Objekte mit vier Ecken wie Bilder (verschieben, drehen, skalieren) und werden als Textur gerastert; im PDF-Export echter, durchsuchbarer Text mit eingebetteter Schrift. Kopierter Text wird mit Strg+V zum Textfeld. Dateiformat-Version 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -1395,6 +1395,7 @@ dependencies = [
|
|||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ fs2 = "0.4"
|
|||||||
hayro = "0.8"
|
hayro = "0.8"
|
||||||
hayro-svg = "0.8"
|
hayro-svg = "0.8"
|
||||||
|
|
||||||
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
||||||
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
||||||
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
|
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
|
||||||
|
- **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||||
|
|
||||||
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
||||||
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
||||||
@@ -225,6 +226,7 @@ cargo run --release
|
|||||||
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
||||||
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
||||||
| **Auswahlwerkzeug (Lasso)** | `V` |
|
| **Auswahlwerkzeug (Lasso)** | `V` |
|
||||||
|
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
|
||||||
| **Auswahl um 90° drehen** | `R` |
|
| **Auswahl um 90° drehen** | `R` |
|
||||||
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
||||||
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
||||||
|
|||||||
+123
-14
@@ -6,6 +6,7 @@
|
|||||||
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
||||||
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
@@ -186,6 +187,13 @@ pub struct CanvasState {
|
|||||||
pub paper_theme: PaperTheme,
|
pub paper_theme: PaperTheme,
|
||||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||||
pub adapt_ink: bool,
|
pub adapt_ink: bool,
|
||||||
|
/// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung)
|
||||||
|
text_specs: HashMap<String, TextSpec>,
|
||||||
|
/// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung
|
||||||
|
/// zeigt es)
|
||||||
|
hidden_text: Option<u64>,
|
||||||
|
/// Auflösung der Texttexturen in Pixeln je Seiteneinheit
|
||||||
|
text_resolution: f32,
|
||||||
|
|
||||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||||
pub live_vertex_buffer: wgpu::Buffer,
|
pub live_vertex_buffer: wgpu::Buffer,
|
||||||
@@ -491,6 +499,9 @@ impl CanvasState {
|
|||||||
current_background,
|
current_background,
|
||||||
paper_theme,
|
paper_theme,
|
||||||
adapt_ink: false,
|
adapt_ink: false,
|
||||||
|
text_specs: HashMap::new(),
|
||||||
|
hidden_text: None,
|
||||||
|
text_resolution: 1.0,
|
||||||
live_vertex_buffer,
|
live_vertex_buffer,
|
||||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||||
live_vertex_count: 0,
|
live_vertex_count: 0,
|
||||||
@@ -789,7 +800,8 @@ impl CanvasState {
|
|||||||
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
|
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
|
||||||
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
|
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
|
||||||
self.set_background(page.background);
|
self.set_background(page.background);
|
||||||
self.image_layer.set_quads(page_image_quads(page));
|
let quads = self.page_image_quads(page, self.hidden_text);
|
||||||
|
self.image_layer.set_quads(quads);
|
||||||
|
|
||||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||||
self.committed_strokes = page.stroke_ranges.clone();
|
self.committed_strokes = page.stroke_ranges.clone();
|
||||||
@@ -836,7 +848,7 @@ impl CanvasState {
|
|||||||
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
|
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
|
||||||
);
|
);
|
||||||
self.neighbors.push(neighbor);
|
self.neighbors.push(neighbor);
|
||||||
image_groups.push(page_image_quads(page));
|
image_groups.push(self.page_image_quads(page, None));
|
||||||
}
|
}
|
||||||
self.image_layer.set_neighbor_quads(image_groups);
|
self.image_layer.set_neighbor_quads(image_groups);
|
||||||
}
|
}
|
||||||
@@ -890,7 +902,74 @@ impl CanvasState {
|
|||||||
&mut self,
|
&mut self,
|
||||||
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
|
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
|
||||||
) {
|
) {
|
||||||
self.image_layer.prepare(&self.device, &self.queue, load);
|
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
|
||||||
|
let resolution = text_resolution(
|
||||||
|
self.viewport.page_width / crate::document::PAGE_WIDTH * self.camera.zoom,
|
||||||
|
);
|
||||||
|
if resolution != self.text_resolution {
|
||||||
|
self.text_resolution = resolution;
|
||||||
|
self.image_layer
|
||||||
|
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
|
||||||
|
}
|
||||||
|
let specs = &self.text_specs;
|
||||||
|
self.image_layer
|
||||||
|
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
|
||||||
|
Some(spec) => crate::image_layer::TextureSource::Ready(crate::text::rasterize(
|
||||||
|
&spec.text,
|
||||||
|
spec.color,
|
||||||
|
resolution * spec.scale,
|
||||||
|
)),
|
||||||
|
None => load(key),
|
||||||
|
});
|
||||||
|
let layer = &self.image_layer;
|
||||||
|
self.text_specs.retain(|key, _| layer.uses(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blendet das gerade bearbeitete Textfeld aus (wirksam ab dem nächsten `sync_page`).
|
||||||
|
pub fn set_hidden_text(&mut self, id: Option<u64>) {
|
||||||
|
self.hidden_text = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten
|
||||||
|
/// Bilder und Textfelder (außer `hidden`) in ihrer Reihenfolge.
|
||||||
|
fn page_image_quads(
|
||||||
|
&mut self,
|
||||||
|
page: &Page,
|
||||||
|
hidden: Option<u64>,
|
||||||
|
) -> Vec<(String, [glam::Vec2; 4])> {
|
||||||
|
let adapt = self.adapt_ink && self.paper_theme.is_dark();
|
||||||
|
let mut quads: Vec<_> = page
|
||||||
|
.pdf_background
|
||||||
|
.iter()
|
||||||
|
.map(|pdf| (pdf.key(), pdf.corners()))
|
||||||
|
.collect();
|
||||||
|
for stroke in &page.strokes {
|
||||||
|
let Some(corners) = stroke.object_corners() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(asset) = &stroke.image {
|
||||||
|
quads.push((asset.clone(), corners));
|
||||||
|
} else if let Some(text) = &stroke.text {
|
||||||
|
if Some(stroke.id) == hidden {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let color = if adapt {
|
||||||
|
crate::background::adapt_ink_color(stroke.color)
|
||||||
|
} else {
|
||||||
|
stroke.color
|
||||||
|
};
|
||||||
|
let key = text_key(text, color);
|
||||||
|
let scale = ((corners[1] - corners[0]).length() / text.wrap_width.max(text.size))
|
||||||
|
.max((corners[3] - corners[0]).length() / text.layout().height());
|
||||||
|
self.text_specs.entry(key.clone()).or_insert(TextSpec {
|
||||||
|
text: text.clone(),
|
||||||
|
color,
|
||||||
|
scale,
|
||||||
|
});
|
||||||
|
quads.push((key, corners));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quads
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
||||||
@@ -1205,23 +1284,53 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder.
|
/// Zu rasterndes Textfeld.
|
||||||
fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> {
|
struct TextSpec {
|
||||||
page.pdf_background
|
text: crate::text::TextBox,
|
||||||
.iter()
|
/// Angezeigte (ggf. aufgehellte) Farbe
|
||||||
.map(|pdf| (pdf.key(), pdf.corners()))
|
color: [f32; 4],
|
||||||
.chain(
|
/// Vergrößerung des Felds durch Skalieren der Auswahl
|
||||||
page.strokes
|
scale: f32,
|
||||||
.iter()
|
}
|
||||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
|
|
||||||
)
|
/// Schlüssel der Texttexturen beginnen hiermit.
|
||||||
.collect()
|
const TEXT_KEY_PREFIX: &str = "text:";
|
||||||
|
|
||||||
|
/// Texturschlüssel eines Textfelds: ändert sich mit Inhalt, Satz und Farbe.
|
||||||
|
fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
text.content.hash(&mut hasher);
|
||||||
|
text.font.hash(&mut hasher);
|
||||||
|
for v in [text.size, text.wrap_width].into_iter().chain(color) {
|
||||||
|
v.to_bits().hash(&mut hasher);
|
||||||
|
}
|
||||||
|
format!("{TEXT_KEY_PREFIX}{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
|
||||||
|
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 4). So wird nicht bei jedem Zoomschritt neu
|
||||||
|
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
|
||||||
|
/// verkleinert wirkt die dünne Schrift blass.
|
||||||
|
fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||||
|
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
|
||||||
|
2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_resolution_is_quantized_and_never_below_screen() {
|
||||||
|
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
|
||||||
|
let r = text_resolution(px);
|
||||||
|
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}");
|
||||||
|
}
|
||||||
|
assert_eq!(text_resolution(0.27), text_resolution(0.28));
|
||||||
|
assert_eq!(text_resolution(100.0), 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_choose_present_mode() {
|
fn test_choose_present_mode() {
|
||||||
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||||
|
|||||||
+53
-11
@@ -371,6 +371,9 @@ pub struct Stroke {
|
|||||||
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
|
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
|
||||||
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
|
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// Textfeld (Text-Werkzeug); die vier Punkte sind wie bei Bildern die Ecken des Felds.
|
||||||
|
/// Textfelder liegen wie Bilder unter der Tinte.
|
||||||
|
pub text: Option<crate::text::TextBox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stroke {
|
impl Stroke {
|
||||||
@@ -404,6 +407,7 @@ impl Stroke {
|
|||||||
width: 5.0,
|
width: 5.0,
|
||||||
finalized: true,
|
finalized: true,
|
||||||
image: None,
|
image: None,
|
||||||
|
text: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,11 +427,48 @@ impl Stroke {
|
|||||||
stroke
|
stroke
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erzeugt ein Textfeld mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||||
|
pub fn new_text(
|
||||||
|
id: u64,
|
||||||
|
text: crate::text::TextBox,
|
||||||
|
corners: [glam::Vec2; 4],
|
||||||
|
color: [f32; 4],
|
||||||
|
) -> Self {
|
||||||
|
let mut stroke = Self::new_image(id, String::new(), corners);
|
||||||
|
stroke.image = None;
|
||||||
|
stroke.color = color;
|
||||||
|
stroke.text = Some(text);
|
||||||
|
stroke
|
||||||
|
}
|
||||||
|
|
||||||
/// Ist dieses Objekt ein Bild?
|
/// Ist dieses Objekt ein Bild?
|
||||||
pub fn is_image(&self) -> bool {
|
pub fn is_image(&self) -> bool {
|
||||||
self.image.is_some()
|
self.image.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ist dieses Objekt ein Textfeld?
|
||||||
|
pub fn is_text(&self) -> bool {
|
||||||
|
self.text.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bild oder Textfeld (Objekt mit vier Ecken statt Tinte)?
|
||||||
|
pub fn is_object(&self) -> bool {
|
||||||
|
self.is_image() || self.is_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken eines Bildes oder Textfelds (oben links, oben rechts, unten rechts, unten links).
|
||||||
|
pub fn object_corners(&self) -> Option<[glam::Vec2; 4]> {
|
||||||
|
if !self.is_object() || self.points.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some([
|
||||||
|
self.points[0].position,
|
||||||
|
self.points[1].position,
|
||||||
|
self.points[2].position,
|
||||||
|
self.points[3].position,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
|
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
|
||||||
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
|
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
|
||||||
if self.image.is_none() || self.points.len() < 4 {
|
if self.image.is_none() || self.points.len() < 4 {
|
||||||
@@ -474,9 +515,9 @@ impl Stroke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
||||||
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
|
/// Bei Bildern und Textfeldern ist der Abstand innerhalb der Fläche 0.
|
||||||
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
|
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
|
||||||
if let Some(corners) = self.image_corners() {
|
if let Some(corners) = self.object_corners() {
|
||||||
if point_in_convex_quad(p, &corners) {
|
if point_in_convex_quad(p, &corners) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
@@ -731,7 +772,8 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
|
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
|
||||||
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
|
/// Tinte hat Vorrang vor Bildern und Textfeldern darunter, damit Notizen darauf
|
||||||
|
/// auswählbar bleiben.
|
||||||
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
|
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
|
||||||
let mut best: Option<(&Stroke, (bool, f32))> = None;
|
let mut best: Option<(&Stroke, (bool, f32))> = None;
|
||||||
for s in &self.strokes {
|
for s in &self.strokes {
|
||||||
@@ -740,7 +782,7 @@ impl Page {
|
|||||||
}
|
}
|
||||||
let d = s.distance_to_point(pos);
|
let d = s.distance_to_point(pos);
|
||||||
if d <= max_dist {
|
if d <= max_dist {
|
||||||
let key = (s.is_image(), d);
|
let key = (s.is_object(), d);
|
||||||
if best.is_none_or(|(_, best_key)| key < best_key) {
|
if best.is_none_or(|(_, best_key)| key < best_key) {
|
||||||
best = Some((s, key));
|
best = Some((s, key));
|
||||||
}
|
}
|
||||||
@@ -783,7 +825,7 @@ impl Page {
|
|||||||
let mut hit_indices = Vec::new();
|
let mut hit_indices = Vec::new();
|
||||||
|
|
||||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||||
if !stroke.is_image()
|
if !stroke.is_object()
|
||||||
&& mode.erases(stroke.tool)
|
&& mode.erases(stroke.tool)
|
||||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||||
{
|
{
|
||||||
@@ -811,8 +853,8 @@ impl Page {
|
|||||||
|
|
||||||
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
|
||||||
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
|
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
|
||||||
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
|
/// Formen als Ganzes entfernt, Bilder und Textfelder nie. Aufeinanderfolgende Schnitte
|
||||||
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
/// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
pub fn erase_partial_at(
|
pub fn erase_partial_at(
|
||||||
&mut self,
|
&mut self,
|
||||||
eraser_pos: glam::Vec2,
|
eraser_pos: glam::Vec2,
|
||||||
@@ -824,7 +866,7 @@ impl Page {
|
|||||||
let mut result = Vec::with_capacity(self.strokes.len());
|
let mut result = Vec::with_capacity(self.strokes.len());
|
||||||
for stroke in std::mem::take(&mut self.strokes) {
|
for stroke in std::mem::take(&mut self.strokes) {
|
||||||
let reach = eraser_radius + stroke.width / 2.0;
|
let reach = eraser_radius + stroke.width / 2.0;
|
||||||
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
|
||||||
result.push(stroke);
|
result.push(stroke);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -900,8 +942,8 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
|
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
|
||||||
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
|
/// Bilder und Textfelder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst
|
||||||
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
|
/// würde jede Auswahl von Notizen auf einem Bild das Bild mitnehmen.
|
||||||
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
|
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
|
||||||
let sel_box = Aabb::new(
|
let sel_box = Aabb::new(
|
||||||
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
||||||
@@ -910,7 +952,7 @@ impl Page {
|
|||||||
self.strokes
|
self.strokes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| {
|
.filter(|s| {
|
||||||
if s.is_image() {
|
if s.is_object() {
|
||||||
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
|
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
|
||||||
} else {
|
} else {
|
||||||
s.aabb.intersects(&sel_box)
|
s.aabb.intersects(&sel_box)
|
||||||
|
|||||||
+88
-2
@@ -2,13 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! Features:
|
//! Features:
|
||||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
||||||
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
|
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern), "Text" (nur bei
|
||||||
|
//! Textfeldern; echter, durchsuchbarer Text mit eingebetteter Schrift) und "Ink"
|
||||||
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
||||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||||
//! - Zwei Export-Strategien:
|
//! - Zwei Export-Strategien:
|
||||||
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
|
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
|
||||||
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
|
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufWriter;
|
use std::io::BufWriter;
|
||||||
@@ -22,6 +24,9 @@ use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
|
|||||||
use crate::document::{
|
use crate::document::{
|
||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
use ab_glyph::Font as _;
|
||||||
|
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
||||||
pub fn sanitize_filename_component(title: &str) -> String {
|
pub fn sanitize_filename_component(title: &str) -> String {
|
||||||
@@ -291,6 +296,7 @@ fn generate_pdf_document_with(
|
|||||||
let title = "ink-notes Notebook";
|
let title = "ink-notes Notebook";
|
||||||
let (doc, page1_idx, layer1_bg) =
|
let (doc, page1_idx, layer1_bg) =
|
||||||
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||||
|
let fonts = embed_text_fonts(&doc, notebook)?;
|
||||||
|
|
||||||
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
|
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
|
||||||
if let Some(title) = ¬ebook.pages[0].title {
|
if let Some(title) = ¬ebook.pages[0].title {
|
||||||
@@ -307,6 +313,9 @@ fn generate_pdf_document_with(
|
|||||||
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
||||||
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
||||||
}
|
}
|
||||||
|
if notebook.pages[0].strokes.iter().any(Stroke::is_text) {
|
||||||
|
render_text_layer(&page1_ref.add_layer("Text"), ¬ebook.pages[0], &fonts);
|
||||||
|
}
|
||||||
|
|
||||||
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
||||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||||
@@ -326,6 +335,9 @@ fn generate_pdf_document_with(
|
|||||||
if page.strokes.iter().any(Stroke::is_image) {
|
if page.strokes.iter().any(Stroke::is_image) {
|
||||||
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
||||||
}
|
}
|
||||||
|
if page.strokes.iter().any(Stroke::is_text) {
|
||||||
|
render_text_layer(&page_ref.add_layer("Text"), page, &fonts);
|
||||||
|
}
|
||||||
|
|
||||||
let layer_ink_ref = page_ref.add_layer("Ink");
|
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||||
@@ -425,7 +437,7 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
|
|||||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
if stroke.is_image() {
|
if stroke.is_object() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(ref shape) = stroke.shape {
|
if let Some(ref shape) = stroke.shape {
|
||||||
@@ -563,6 +575,80 @@ fn render_image_layer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bettet die von Textfeldern des Notizbuchs verwendeten Schriften ein.
|
||||||
|
fn embed_text_fonts(
|
||||||
|
doc: &PdfDocumentReference,
|
||||||
|
notebook: &Notebook,
|
||||||
|
) -> Result<HashMap<TextFont, printpdf::IndirectFontRef>, Box<dyn Error>> {
|
||||||
|
let mut fonts = HashMap::new();
|
||||||
|
let used = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|p| &p.strokes)
|
||||||
|
.filter_map(|s| s.text.as_ref().map(|t| t.font));
|
||||||
|
for font in used {
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(entry) = fonts.entry(font) {
|
||||||
|
entry.insert(doc.add_external_font(font.bytes())?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(fonts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert die Textfelder einer Seite als echten Text: Jedes Feld wird in seinem eigenen
|
||||||
|
/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine
|
||||||
|
/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung.
|
||||||
|
fn render_text_layer(
|
||||||
|
layer: &printpdf::PdfLayerReference,
|
||||||
|
page: &Page,
|
||||||
|
fonts: &HashMap<TextFont, printpdf::IndirectFontRef>,
|
||||||
|
) {
|
||||||
|
for stroke in &page.strokes {
|
||||||
|
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(font) = fonts.get(&text.font) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let layout = text.layout();
|
||||||
|
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||||
|
let (origin, [right, down]) = crate::text::frame_axes(&corners, size);
|
||||||
|
// Seite → PDF-Punkte: x · k, Höhe − y · k
|
||||||
|
let k = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4;
|
||||||
|
let o = page_to_pdf_pt(origin);
|
||||||
|
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
|
||||||
|
layer.save_graphics_state();
|
||||||
|
layer.set_ctm(printpdf::CurTransMat::Raw(matrix));
|
||||||
|
layer.set_fill_color(Color::Rgb(Rgb::new(
|
||||||
|
stroke.color[0],
|
||||||
|
stroke.color[1],
|
||||||
|
stroke.color[2],
|
||||||
|
None,
|
||||||
|
)));
|
||||||
|
layer.begin_text_section();
|
||||||
|
layer.set_font(font, text.size);
|
||||||
|
for (i, line) in layout.lines.iter().enumerate() {
|
||||||
|
if line.text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
layer.set_text_matrix(printpdf::TextMatrix::Translate(
|
||||||
|
printpdf::Pt(0.0),
|
||||||
|
printpdf::Pt(-layout.baseline(i)),
|
||||||
|
));
|
||||||
|
// Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em)
|
||||||
|
let face = text.font.face();
|
||||||
|
let per_em = 1000.0 / face.units_per_em().unwrap_or(1000.0);
|
||||||
|
let mut previous: Option<ab_glyph::GlyphId> = None;
|
||||||
|
layer.write_positioned_codepoints(line.glyphs.iter().map(|&(id, _)| {
|
||||||
|
let kern = previous.map_or(0.0, |p| face.kern_unscaled(p, id));
|
||||||
|
previous = Some(id);
|
||||||
|
((-kern * per_em).round() as i64, id.0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
layer.end_text_section();
|
||||||
|
layer.restore_graphics_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Graue 1×1-Fläche für fehlende Bilder.
|
/// Graue 1×1-Fläche für fehlende Bilder.
|
||||||
fn placeholder_xobject() -> printpdf::ImageXObject {
|
fn placeholder_xobject() -> printpdf::ImageXObject {
|
||||||
rgb_xobject(1, 1, vec![200, 200, 200])
|
rgb_xobject(1, 1, vec![200, 200, 200])
|
||||||
|
|||||||
@@ -264,6 +264,18 @@ impl ImageLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
|
||||||
|
pub fn uses(&self, key: &str) -> bool {
|
||||||
|
self.all_quads().any(|(k, _)| k == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten
|
||||||
|
/// `prepare` neu angefordert).
|
||||||
|
pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) {
|
||||||
|
self.textures.retain(|key, _| !matches(key));
|
||||||
|
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||||
|
}
|
||||||
|
|
||||||
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
|
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||||
pub fn is_waiting(&self) -> bool {
|
pub fn is_waiting(&self) -> bool {
|
||||||
self.all_quads()
|
self.all_quads()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod selection;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod text;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
|||||||
+235
-13
@@ -253,13 +253,15 @@ fn page_to_focus(
|
|||||||
(!grabs_selection).then_some(index)
|
(!grabs_selection).then_some(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
|
/// Zwischenablage: interne Objekte (Strg+C in ink-notes), Bilder aus der
|
||||||
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
|
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder) und Text.
|
||||||
struct ClipboardBridge {
|
struct ClipboardBridge {
|
||||||
system: Option<arboard::Clipboard>,
|
system: Option<arboard::Clipboard>,
|
||||||
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
|
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
|
||||||
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
|
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
|
||||||
image_hash_at_copy: Option<[u8; 32]>,
|
image_hash_at_copy: Option<[u8; 32]>,
|
||||||
|
/// Text der Systemzwischenablage zum Zeitpunkt des letzten internen Kopierens (wie oben)
|
||||||
|
text_at_copy: Option<String>,
|
||||||
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
|
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
|
||||||
source_dir: Option<PathBuf>,
|
source_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -269,6 +271,7 @@ impl ClipboardBridge {
|
|||||||
Self {
|
Self {
|
||||||
system: None,
|
system: None,
|
||||||
image_hash_at_copy: None,
|
image_hash_at_copy: None,
|
||||||
|
text_at_copy: None,
|
||||||
source_dir: None,
|
source_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,9 +293,20 @@ impl ClipboardBridge {
|
|||||||
Some((image.to_owned_img(), hash))
|
Some((image.to_owned_img(), hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merkt sich beim internen Kopieren das aktuelle Systembild und das Quell-Notizbuch.
|
/// Text in der Systemzwischenablage (nicht nur Leerraum).
|
||||||
|
fn system_text(&mut self) -> Option<String> {
|
||||||
|
if self.system.is_none() {
|
||||||
|
self.system = arboard::Clipboard::new().ok();
|
||||||
|
}
|
||||||
|
let text = self.system.as_mut()?.get_text().ok()?;
|
||||||
|
(!text.trim().is_empty()).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merkt sich beim internen Kopieren das aktuelle Systembild, den Text und das
|
||||||
|
/// Quell-Notizbuch.
|
||||||
fn note_internal_copy(&mut self, storage_dir: &Path) {
|
fn note_internal_copy(&mut self, storage_dir: &Path) {
|
||||||
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
|
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
|
||||||
|
self.text_at_copy = self.system_text();
|
||||||
self.source_dir = Some(storage_dir.to_path_buf());
|
self.source_dir = Some(storage_dir.to_path_buf());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +382,124 @@ fn insert_image_on_page(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fügt `content` als Textfeld (Schrift des Text-Werkzeugs) in der Mitte des sichtbaren
|
||||||
|
/// Bereichs ein, wählt es aus und schaltet auf das Auswahlwerkzeug zum Verschieben.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn insert_text_on_page(
|
||||||
|
content: &str,
|
||||||
|
canvas: &mut CanvasState,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
autosave: &mut AutosaveController,
|
||||||
|
selection_state: &mut SelectionState,
|
||||||
|
ui_state: &mut UiState,
|
||||||
|
) {
|
||||||
|
let center = visible_page_center(canvas);
|
||||||
|
let mut edit = ink_notes::text::TextEditing::new_at(
|
||||||
|
notebook.current_page().id,
|
||||||
|
Vec2::new((center.x - 600.0).max(150.0), center.y),
|
||||||
|
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||||
|
ui_state.text_font,
|
||||||
|
ui_state.text_color,
|
||||||
|
);
|
||||||
|
edit.text.content = content.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
let id = notebook.next_stroke_id();
|
||||||
|
let Some(stroke) = edit.to_stroke(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.add_stroke(stroke);
|
||||||
|
canvas.sync_page(page);
|
||||||
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
|
autosave.mark_dirty(notebook, manifest);
|
||||||
|
selection_state.select_single(id);
|
||||||
|
ui_state.active_tool = ActiveTool::Select;
|
||||||
|
ui_state.set_export_message("Text eingefügt – mit dem Text-Werkzeug (T) änderbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
|
||||||
|
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
|
||||||
|
fn finish_text_edit(
|
||||||
|
canvas: &mut CanvasState,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
autosave: &mut AutosaveController,
|
||||||
|
ui_state: &mut UiState,
|
||||||
|
) {
|
||||||
|
let Some(edit) = ui_state.text_edit.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
canvas.set_hidden_text(None);
|
||||||
|
if let Some(index) = notebook.pages.iter().position(|p| p.id == edit.page_id) {
|
||||||
|
if edit.is_changed() && !notebook.pages[index].is_readonly() {
|
||||||
|
let id = match &edit.original {
|
||||||
|
Some(original) => original.id,
|
||||||
|
None => notebook.next_stroke_id(),
|
||||||
|
};
|
||||||
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
|
let page = &mut notebook.pages[index];
|
||||||
|
match (edit.original.clone(), edit.to_stroke(id)) {
|
||||||
|
(None, Some(stroke)) => page.add_stroke(stroke),
|
||||||
|
(Some(before), Some(after)) => {
|
||||||
|
page.commit_modified_strokes(vec![before], vec![after])
|
||||||
|
}
|
||||||
|
(Some(before), None) => {
|
||||||
|
page.remove_strokes_by_ids(&[before.id]);
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
manifest.sync_from_notebook(notebook);
|
||||||
|
autosave.mark_page_dirty(¬ebook.pages[index], manifest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-Werkzeug: Tippen auf das Blatt beendet eine laufende Bearbeitung und öffnet das
|
||||||
|
/// Textfeld unter `page_pt` zum Bearbeiten – oder ein neues an dieser Stelle.
|
||||||
|
fn text_tool_click(
|
||||||
|
page_pt: Vec2,
|
||||||
|
canvas: &mut CanvasState,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
autosave: &mut AutosaveController,
|
||||||
|
ui_state: &mut UiState,
|
||||||
|
) {
|
||||||
|
finish_text_edit(canvas, notebook, manifest, autosave, ui_state);
|
||||||
|
let page = notebook.current_page();
|
||||||
|
if page.is_readonly() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hit = page
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|s| s.is_text() && s.distance_to_point(page_pt) <= 0.0);
|
||||||
|
let edit = match hit {
|
||||||
|
Some(stroke) => {
|
||||||
|
let edit = ink_notes::text::TextEditing::of_stroke(page.id, stroke);
|
||||||
|
if let Some(edit) = &edit {
|
||||||
|
// Die Schriftknöpfe zeigen die Schrift des Felds und ändern sie
|
||||||
|
ui_state.text_size_pt =
|
||||||
|
(edit.text.size / ink_notes::text::UNITS_PER_PT * 2.0).round() / 2.0;
|
||||||
|
ui_state.text_font = edit.text.font;
|
||||||
|
ui_state.text_color = edit.color;
|
||||||
|
canvas.set_hidden_text(Some(stroke.id));
|
||||||
|
}
|
||||||
|
edit
|
||||||
|
}
|
||||||
|
None => Some(ink_notes::text::TextEditing::new_at(
|
||||||
|
page.id,
|
||||||
|
page_pt,
|
||||||
|
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||||
|
ui_state.text_font,
|
||||||
|
ui_state.text_color,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
ui_state.text_edit = edit;
|
||||||
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
|
}
|
||||||
|
|
||||||
/// Textur für einen Schlüssel der Bildebene: `<pdf>#<seite>` rendert der Hintergrund-Thread,
|
/// Textur für einen Schlüssel der Bildebene: `<pdf>#<seite>` rendert der Hintergrund-Thread,
|
||||||
/// alles andere ist eine Bilddatei im Asset-Ordner.
|
/// alles andere ist eine Bilddatei im Asset-Ordner.
|
||||||
fn texture_for(
|
fn texture_for(
|
||||||
@@ -503,6 +635,23 @@ fn paste(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aus anderen Programmen kopierter Text wird ein Textfeld
|
||||||
|
if let Some(text) = clipboard.system_text() {
|
||||||
|
if selection_state.clipboard.is_empty() || clipboard.text_at_copy.as_deref() != Some(&text)
|
||||||
|
{
|
||||||
|
insert_text_on_page(
|
||||||
|
&text,
|
||||||
|
canvas,
|
||||||
|
notebook,
|
||||||
|
manifest,
|
||||||
|
autosave,
|
||||||
|
selection_state,
|
||||||
|
ui_state,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bilder aus einem anderen Notizbuch mitnehmen
|
// Bilder aus einem anderen Notizbuch mitnehmen
|
||||||
if let Some(source) = clipboard
|
if let Some(source) = clipboard
|
||||||
.source_dir
|
.source_dir
|
||||||
@@ -739,10 +888,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||||
|
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
canvas.set_stroke_color(ui_state.pen_color);
|
canvas.set_stroke_color(ui_state.pen_color);
|
||||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
|
||||||
|
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
@@ -801,7 +950,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ===");
|
println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ===");
|
||||||
println!("Toolbar:");
|
println!("Toolbar:");
|
||||||
println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso)");
|
println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso) / Text");
|
||||||
println!(" Farbpalette (Multicolor), Strichbreite, Undo/Redo, Zoom, Navigation & PDF-Export");
|
println!(" Farbpalette (Multicolor), Strichbreite, Undo/Redo, Zoom, Navigation & PDF-Export");
|
||||||
println!("Objektauswahl & Transformation:");
|
println!("Objektauswahl & Transformation:");
|
||||||
println!(" V : Auswahlwerkzeug (Lasso) umschalten");
|
println!(" V : Auswahlwerkzeug (Lasso) umschalten");
|
||||||
@@ -813,6 +962,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!(" Palette-Klick : Ausgewählte Objekte direkt umfärben");
|
println!(" Palette-Klick : Ausgewählte Objekte direkt umfärben");
|
||||||
println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen");
|
println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen");
|
||||||
println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)");
|
println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)");
|
||||||
|
println!("Text:");
|
||||||
|
println!(" T : Text-Werkzeug umschalten");
|
||||||
|
println!(" Klick ins Blatt: Neues Textfeld (vorhandenes Textfeld: bearbeiten)");
|
||||||
|
println!(" Esc : Textbearbeitung beenden");
|
||||||
println!("Tastatur- & Maus-Shortcuts:");
|
println!("Tastatur- & Maus-Shortcuts:");
|
||||||
println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom");
|
println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom");
|
||||||
println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor");
|
println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor");
|
||||||
@@ -1044,7 +1197,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| stylus_barrel_pressed
|
|| stylus_barrel_pressed
|
||||||
|| ui_state.active_tool == ActiveTool::Eraser;
|
|| ui_state.active_tool == ActiveTool::Eraser;
|
||||||
|
|
||||||
if should_erase {
|
if !should_erase && ui_state.active_tool == ActiveTool::Text {
|
||||||
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
|
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
} else if should_erase {
|
||||||
is_erasing = true;
|
is_erasing = true;
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
if notebook.erase_at(page_pt,
|
if notebook.erase_at(page_pt,
|
||||||
@@ -1206,6 +1365,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||||
match autosave.shutdown() {
|
match autosave.shutdown() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -1288,6 +1448,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offenes Textfeld ohne Fokus (z. B. nach einem Klick in die Leiste):
|
||||||
|
// Tasten nicht als Kürzel deuten, sondern das Feld wieder fokussieren
|
||||||
|
if let Some(edit) = &mut ui_state.text_edit {
|
||||||
|
if key_state == ElementState::Pressed
|
||||||
|
&& !matches!(logical_key, Key::Named(NamedKey::Escape))
|
||||||
|
{
|
||||||
|
edit.focus = true;
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Key::Named(NamedKey::Space) = &logical_key {
|
if let Key::Named(NamedKey::Space) = &logical_key {
|
||||||
is_space_pressed = key_state == ElementState::Pressed;
|
is_space_pressed = key_state == ElementState::Pressed;
|
||||||
if !is_space_pressed && is_panning {
|
if !is_space_pressed && is_panning {
|
||||||
@@ -1322,8 +1494,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.is_update_dialog_open = false;
|
ui_state.is_update_dialog_open = false;
|
||||||
ui_state.show_updater_config = false;
|
ui_state.show_updater_config = false;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
} else if let Some(edit) = &mut ui_state.text_edit {
|
||||||
else if !selection_state.is_empty() {
|
edit.finish = true;
|
||||||
|
changed = true;
|
||||||
|
} else if !selection_state.is_empty() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -1334,6 +1508,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Undo: Strg + Z
|
// Undo: Strg + Z
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
|
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
if notebook.current_page_mut().undo() {
|
if notebook.current_page_mut().undo() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
@@ -1348,6 +1523,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if (c.eq_ignore_ascii_case("y") && ctrl)
|
if (c.eq_ignore_ascii_case("y") && ctrl)
|
||||||
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
|
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
|
||||||
{
|
{
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
if notebook.current_page_mut().redo() {
|
if notebook.current_page_mut().redo() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
@@ -1438,7 +1614,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Radierer umschalten: E
|
// Radierer umschalten: E
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
|
||||||
ui_state.active_tool = match ui_state.active_tool {
|
ui_state.active_tool = match ui_state.active_tool {
|
||||||
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select => ActiveTool::Eraser,
|
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser,
|
||||||
ActiveTool::Eraser => ActiveTool::Pen,
|
ActiveTool::Eraser => ActiveTool::Pen,
|
||||||
};
|
};
|
||||||
ui_state.is_pen_menu_open = false;
|
ui_state.is_pen_menu_open = false;
|
||||||
@@ -1630,6 +1806,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text-Werkzeug umschalten: T
|
||||||
|
Key::Character(c) if c.eq_ignore_ascii_case("t") && !ctrl => {
|
||||||
|
ui_state.active_tool = if ui_state.active_tool == ActiveTool::Text {
|
||||||
|
ActiveTool::Pen
|
||||||
|
} else {
|
||||||
|
ActiveTool::Text
|
||||||
|
};
|
||||||
|
ui_state.is_pen_menu_open = false;
|
||||||
|
ui_state.is_highlighter_menu_open = false;
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// Formen-Werkzeug umschalten: S (ohne Strg) oder F
|
// Formen-Werkzeug umschalten: S (ohne Strg) oder F
|
||||||
Key::Character(c) if (c.eq_ignore_ascii_case("s") || c.eq_ignore_ascii_case("f")) && !ctrl => {
|
Key::Character(c) if (c.eq_ignore_ascii_case("s") || c.eq_ignore_ascii_case("f")) && !ctrl => {
|
||||||
ui_state.active_tool = if ui_state.active_tool == ActiveTool::Shape {
|
ui_state.active_tool = if ui_state.active_tool == ActiveTool::Shape {
|
||||||
@@ -1643,6 +1832,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Beenden: Strg + Q
|
// Beenden: Strg + Q
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||||
match autosave.shutdown() {
|
match autosave.shutdown() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -1700,6 +1890,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
} else if button == MouseButton::Left {
|
} else if button == MouseButton::Left {
|
||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
|
|
||||||
|
// Text-Werkzeug: Textfeld öffnen
|
||||||
|
if ui_state.active_tool == ActiveTool::Text {
|
||||||
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
|
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Objektauswahl & Transformation im Select-Modus
|
// 2. Objektauswahl & Transformation im Select-Modus
|
||||||
if ui_state.active_tool == ActiveTool::Select {
|
if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(sx, sy);
|
let screen_vec = Vec2::new(sx, sy);
|
||||||
@@ -2119,6 +2319,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if ui_state.active_tool == ActiveTool::Text {
|
||||||
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
|
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
} else if ui_state.active_tool == ActiveTool::Select {
|
} else if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(tx, ty);
|
let screen_vec = Vec2::new(tx, ty);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
@@ -2409,7 +2615,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_erasing
|
|| is_erasing
|
||||||
|| !selection_state.is_empty()
|
|| !selection_state.is_empty()
|
||||||
|| selection_state.drag_mode != DragMode::None
|
|| selection_state.drag_mode != DragMode::None
|
||||||
|| ui_state.ruler_drag.is_some();
|
|| ui_state.ruler_drag.is_some()
|
||||||
|
|| ui_state.text_edit.is_some();
|
||||||
if scrolled && !busy {
|
if scrolled && !busy {
|
||||||
last_scroll = Some(scroll);
|
last_scroll = Some(scroll);
|
||||||
let target = canvas
|
let target = canvas
|
||||||
@@ -2474,7 +2681,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.set_stroke_color(ui_state.pen_color);
|
canvas.set_stroke_color(ui_state.pen_color);
|
||||||
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
|
||||||
}
|
}
|
||||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||||
}
|
}
|
||||||
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
@@ -2495,7 +2702,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.highlighter_color = color;
|
ui_state.highlighter_color = color;
|
||||||
canvas.set_stroke_color(color);
|
canvas.set_stroke_color(color);
|
||||||
}
|
}
|
||||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2509,7 +2716,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.highlighter_stroke_width_mm = width;
|
ui_state.highlighter_stroke_width_mm = width;
|
||||||
canvas.set_stroke_width_mm(width);
|
canvas.set_stroke_width_mm(width);
|
||||||
}
|
}
|
||||||
ActiveTool::Eraser | ActiveTool::Select => {}
|
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {}
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2579,6 +2786,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::Undo => {
|
UiAction::Undo => {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
if notebook.current_page_mut().undo() {
|
if notebook.current_page_mut().undo() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
@@ -2588,6 +2796,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::Redo => {
|
UiAction::Redo => {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
if notebook.current_page_mut().redo() {
|
if notebook.current_page_mut().redo() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
@@ -3031,6 +3240,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
UiAction::SetPaperTheme { theme, adapt_ink } => {
|
UiAction::SetPaperTheme { theme, adapt_ink } => {
|
||||||
canvas.set_paper_theme(theme, adapt_ink);
|
canvas.set_paper_theme(theme, adapt_ink);
|
||||||
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
ui_settings.paper_theme = theme;
|
ui_settings.paper_theme = theme;
|
||||||
ui_settings.adapt_ink_colors = adapt_ink;
|
ui_settings.adapt_ink_colors = adapt_ink;
|
||||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
@@ -3052,6 +3262,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
|
||||||
|
let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| {
|
||||||
|
edit.finish
|
||||||
|
|| ui_state.active_tool != ActiveTool::Text
|
||||||
|
|| edit.page_id != notebook.current_page().id
|
||||||
|
});
|
||||||
|
if finish_text {
|
||||||
|
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// 4. egui Frame beenden & Primitives tessellieren
|
// 4. egui Frame beenden & Primitives tessellieren
|
||||||
let full_output = egui_ctx.end_frame();
|
let full_output = egui_ctx.end_frame();
|
||||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||||
|
|||||||
@@ -215,6 +215,87 @@ mod tests {
|
|||||||
assert!(svg.trim_end().ends_with("</svg>"));
|
assert!(svg.trim_end().ends_with("</svg>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
|
||||||
|
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
|
||||||
|
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||||||
|
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
|
||||||
|
if p[3] > 128 {
|
||||||
|
let (x, y) = (i as u32 % width, i as u32 / width);
|
||||||
|
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_with_text() -> (Page, crate::text::TextBox) {
|
||||||
|
let text = crate::text::TextBox::new(
|
||||||
|
"Hallo Welt\nzweite Zeile".into(),
|
||||||
|
100.0,
|
||||||
|
crate::text::TextFont::Sans,
|
||||||
|
1200.0,
|
||||||
|
);
|
||||||
|
let size = glam::Vec2::new(1200.0, text.layout().height());
|
||||||
|
let corners = crate::text::frame_corners(
|
||||||
|
glam::Vec2::new(300.0, 400.0),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||||
|
page.strokes.push(Stroke::new_text(
|
||||||
|
3,
|
||||||
|
text.clone(),
|
||||||
|
corners,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
(page, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exported_text_matches_screen_rendering() {
|
||||||
|
let (page, text) = page_with_text();
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||||
|
let exported = ink_bounds(png.width(), png.as_raw());
|
||||||
|
|
||||||
|
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
|
||||||
|
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
|
||||||
|
let local = ink_bounds(screen.width, &screen.pixels);
|
||||||
|
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
|
||||||
|
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
|
||||||
|
for (a, b) in [
|
||||||
|
(exported.0, expected.0),
|
||||||
|
(exported.1, expected.1),
|
||||||
|
(exported.2, expected.2),
|
||||||
|
(exported.3, expected.3),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
a.abs_diff(b) <= 3,
|
||||||
|
"PDF {exported:?} ≠ Bildschirm {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exported_text_follows_rotation() {
|
||||||
|
let (mut page, _) = page_with_text();
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let straight = ink_bounds(png.width(), png.as_raw());
|
||||||
|
|
||||||
|
let center = page.strokes[0].aabb.min;
|
||||||
|
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let rotated = ink_bounds(png.width(), png.as_raw());
|
||||||
|
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
|
||||||
|
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
|
||||||
|
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
|
||||||
|
assert!(
|
||||||
|
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
|
||||||
|
"{straight:?} → {rotated:?}"
|
||||||
|
);
|
||||||
|
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||||
|
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_selection_image_is_cropped() {
|
fn test_selection_image_is_cropped() {
|
||||||
let page = page_with_line();
|
let page = page_with_line();
|
||||||
|
|||||||
+47
-1
@@ -73,6 +73,9 @@ pub struct StrokeData {
|
|||||||
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
|
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// Textfeld; die Samples sind dann die vier Ecken des Felds
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<crate::text::TextBox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Stroke> for StrokeData {
|
impl From<&Stroke> for StrokeData {
|
||||||
@@ -86,6 +89,7 @@ impl From<&Stroke> for StrokeData {
|
|||||||
width: stroke.width,
|
width: stroke.width,
|
||||||
finalized: stroke.finalized,
|
finalized: stroke.finalized,
|
||||||
image: stroke.image.clone(),
|
image: stroke.image.clone(),
|
||||||
|
text: stroke.text.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,14 @@ impl StrokeData {
|
|||||||
.copied()
|
.copied()
|
||||||
.map(StylusSample::from)
|
.map(StylusSample::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
if let (Some(text), [a, b, c, d, ..]) = (&self.text, stylus_samples.as_slice()) {
|
||||||
|
return Stroke::new_text(
|
||||||
|
self.id,
|
||||||
|
text.clone(),
|
||||||
|
[a.position, b.position, c.position, d.position],
|
||||||
|
self.color,
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(asset) = &self.image {
|
if let Some(asset) = &self.image {
|
||||||
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
|
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
|
||||||
let mut stroke = Stroke::new_image(
|
let mut stroke = Stroke::new_image(
|
||||||
@@ -153,7 +165,8 @@ impl StrokeData {
|
|||||||
/// verlieren.
|
/// verlieren.
|
||||||
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||||
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 6;
|
/// - Version 7: Textfelder (`text` am Strich).
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -2479,6 +2492,39 @@ mod tests {
|
|||||||
assert!(dir.join("assets").join(&imported.asset).exists());
|
assert!(dir.join("assets").join(&imported.asset).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_boxes_round_trip_through_page_file() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Mit Text").unwrap();
|
||||||
|
let text = crate::text::TextBox::new(
|
||||||
|
"Überschrift\n\tfn main() {}".into(),
|
||||||
|
42.0,
|
||||||
|
crate::text::TextFont::Mono,
|
||||||
|
900.0,
|
||||||
|
);
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(100.0, 100.0),
|
||||||
|
glam::Vec2::new(1000.0, 100.0),
|
||||||
|
glam::Vec2::new(1000.0, 220.0),
|
||||||
|
glam::Vec2::new(100.0, 220.0),
|
||||||
|
];
|
||||||
|
notebook.current_page_mut().strokes.push(Stroke::new_text(
|
||||||
|
8,
|
||||||
|
text.clone(),
|
||||||
|
corners,
|
||||||
|
[0.15, 0.35, 0.85, 1.0],
|
||||||
|
));
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
|
||||||
|
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
let stroke = &reloaded.current_page().strokes[0];
|
||||||
|
assert_eq!(stroke.text.as_ref(), Some(&text));
|
||||||
|
assert_eq!(stroke.object_corners(), Some(corners));
|
||||||
|
assert_eq!(stroke.color, [0.15, 0.35, 0.85, 1.0]);
|
||||||
|
assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_create_notebook_from_pdf() {
|
fn test_create_notebook_from_pdf() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+541
@@ -0,0 +1,541 @@
|
|||||||
|
//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung.
|
||||||
|
//!
|
||||||
|
//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text
|
||||||
|
//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen ×
|
||||||
|
//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken
|
||||||
|
//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text.
|
||||||
|
//!
|
||||||
|
//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui)
|
||||||
|
//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::assets::RgbaImage;
|
||||||
|
use crate::document::{Stroke, PAGE_WIDTH, UNITS_PER_MM};
|
||||||
|
|
||||||
|
/// Schriftart eines Textfelds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||||
|
pub enum TextFont {
|
||||||
|
/// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen
|
||||||
|
#[default]
|
||||||
|
Sans,
|
||||||
|
/// Festbreitenschrift (Hack) für Code
|
||||||
|
Mono,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextFont {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => "Normal",
|
||||||
|
TextFont::Mono => "Code",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name der Schrift in den egui-Standardschriften.
|
||||||
|
fn egui_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => "Ubuntu-Light",
|
||||||
|
TextFont::Mono => "Hack",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung).
|
||||||
|
pub fn egui_family(self) -> egui::FontFamily {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => egui::FontFamily::Proportional,
|
||||||
|
TextFont::Mono => egui::FontFamily::Monospace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften).
|
||||||
|
pub fn bytes(self) -> &'static [u8] {
|
||||||
|
static DEFINITIONS: OnceLock<egui::FontDefinitions> = OnceLock::new();
|
||||||
|
let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default);
|
||||||
|
&definitions.font_data[self.egui_name()].font
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn face(self) -> &'static FontRef<'static> {
|
||||||
|
static SANS: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||||
|
static MONO: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||||
|
let cell = match self {
|
||||||
|
TextFont::Sans => &SANS,
|
||||||
|
TextFont::Mono => &MONO,
|
||||||
|
};
|
||||||
|
cell.get_or_init(|| {
|
||||||
|
FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn units_per_em(self) -> f32 {
|
||||||
|
self.face().units_per_em().unwrap_or(1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und
|
||||||
|
/// dieses Modul messen die Schriftgröße als Em.
|
||||||
|
pub fn height_per_em(self) -> f32 {
|
||||||
|
let face = self.face();
|
||||||
|
(face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seiteneinheiten je typografischem Punkt.
|
||||||
|
pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM;
|
||||||
|
/// Wählbare Schriftgrößen in Punkt.
|
||||||
|
pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0];
|
||||||
|
pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0;
|
||||||
|
/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm).
|
||||||
|
pub const MIN_WRAP_WIDTH: f32 = 300.0;
|
||||||
|
/// Abstand neuer Textfelder zum rechten Blattrand (1 cm).
|
||||||
|
pub const RIGHT_MARGIN: f32 = 100.0;
|
||||||
|
/// Ein Tabulator wird durch so viele Leerzeichen ersetzt.
|
||||||
|
const TAB_WIDTH: usize = 4;
|
||||||
|
|
||||||
|
/// Inhalt und Satz eines Textfelds.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TextBox {
|
||||||
|
pub content: String,
|
||||||
|
/// Schriftgröße (Em) in Seiteneinheiten
|
||||||
|
pub size: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub font: TextFont,
|
||||||
|
/// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds)
|
||||||
|
pub wrap_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre
|
||||||
|
/// Glyphen mit x-Position.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct LaidLine {
|
||||||
|
pub text: String,
|
||||||
|
pub glyphs: Vec<(GlyphId, f32)>,
|
||||||
|
pub width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TextLayout {
|
||||||
|
pub lines: Vec<LaidLine>,
|
||||||
|
/// Oberlänge (Abstand Zeilenoberkante → Grundlinie)
|
||||||
|
pub ascent: f32,
|
||||||
|
pub line_height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextLayout {
|
||||||
|
/// Höhe des Felds; ein leeres Feld ist eine Zeile hoch.
|
||||||
|
pub fn height(&self) -> f32 {
|
||||||
|
self.lines.len().max(1) as f32 * self.line_height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grundlinie der Zeile `i` (von der Oberkante des Felds).
|
||||||
|
pub fn baseline(&self, i: usize) -> f32 {
|
||||||
|
i as f32 * self.line_height + self.ascent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBox {
|
||||||
|
pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
content,
|
||||||
|
size,
|
||||||
|
font,
|
||||||
|
wrap_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten.
|
||||||
|
pub fn box_size(&self) -> glam::Vec2 {
|
||||||
|
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
|
||||||
|
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
|
||||||
|
pub fn layout(&self) -> TextLayout {
|
||||||
|
let face = self.font.face();
|
||||||
|
let em = self.size / self.font.units_per_em();
|
||||||
|
let line_height =
|
||||||
|
(face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em;
|
||||||
|
let width = self.wrap_width.max(self.size);
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for paragraph in self.content.split('\n') {
|
||||||
|
let mut chars: Vec<(char, GlyphId, f32)> = paragraph
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| {
|
||||||
|
let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) };
|
||||||
|
std::iter::repeat_n(c, n)
|
||||||
|
})
|
||||||
|
.filter_map(|c| {
|
||||||
|
let id = face.glyph_id(c);
|
||||||
|
(id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen
|
||||||
|
for i in 1..chars.len() {
|
||||||
|
chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em;
|
||||||
|
}
|
||||||
|
let mut start = 0;
|
||||||
|
let mut x = 0.0;
|
||||||
|
// Letztes Leerzeichen der Zeile (möglicher Umbruch)
|
||||||
|
let mut last_space: Option<usize> = None;
|
||||||
|
for i in 0..chars.len() {
|
||||||
|
let (c, _, advance) = chars[i];
|
||||||
|
if c != ' ' && x + advance > width && i > start {
|
||||||
|
let end = match last_space {
|
||||||
|
Some(space) if space > start => space,
|
||||||
|
_ => i,
|
||||||
|
};
|
||||||
|
lines.push(laid_line(&chars[start..end]));
|
||||||
|
start = end;
|
||||||
|
while start < i && chars[start].0 == ' ' {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
x = chars[start..i].iter().map(|g| g.2).sum();
|
||||||
|
last_space = None;
|
||||||
|
}
|
||||||
|
x += advance;
|
||||||
|
if c == ' ' {
|
||||||
|
last_space = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(laid_line(&chars[start..]));
|
||||||
|
}
|
||||||
|
// Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile
|
||||||
|
TextLayout {
|
||||||
|
lines,
|
||||||
|
ascent: face.ascent_unscaled() * em,
|
||||||
|
line_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine {
|
||||||
|
let mut x = 0.0;
|
||||||
|
let mut glyphs = Vec::with_capacity(chars.len());
|
||||||
|
let mut width: f32 = 0.0;
|
||||||
|
for &(c, id, advance) in chars {
|
||||||
|
glyphs.push((id, x));
|
||||||
|
x += advance;
|
||||||
|
if c != ' ' {
|
||||||
|
width = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaidLine {
|
||||||
|
text: chars.iter().map(|g| g.0).collect(),
|
||||||
|
glyphs,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen
|
||||||
|
/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts,
|
||||||
|
/// unten rechts, unten links.
|
||||||
|
pub fn frame_corners(
|
||||||
|
origin: glam::Vec2,
|
||||||
|
axes: [glam::Vec2; 2],
|
||||||
|
size: glam::Vec2,
|
||||||
|
) -> [glam::Vec2; 4] {
|
||||||
|
let right = axes[0] * size.x;
|
||||||
|
let down = axes[1] * size.y;
|
||||||
|
[origin, origin + right, origin + right + down, origin + down]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`.
|
||||||
|
pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) {
|
||||||
|
let [tl, tr, _, bl] = *corners;
|
||||||
|
(
|
||||||
|
tl,
|
||||||
|
[(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TextEditing {
|
||||||
|
/// Seite, auf der das Feld liegt
|
||||||
|
pub page_id: u64,
|
||||||
|
/// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen
|
||||||
|
pub original: Option<Stroke>,
|
||||||
|
/// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten)
|
||||||
|
pub origin: glam::Vec2,
|
||||||
|
pub axes: [glam::Vec2; 2],
|
||||||
|
pub text: TextBox,
|
||||||
|
pub color: [f32; 4],
|
||||||
|
/// Beim nächsten Bild den Tastaturfokus auf das Feld setzen
|
||||||
|
pub focus: bool,
|
||||||
|
/// Bearbeitung beenden (Esc)
|
||||||
|
pub finish: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEditing {
|
||||||
|
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
|
||||||
|
/// Blattrand (mindestens 3 cm breit).
|
||||||
|
pub fn new_at(
|
||||||
|
page_id: u64,
|
||||||
|
pos: glam::Vec2,
|
||||||
|
size: f32,
|
||||||
|
font: TextFont,
|
||||||
|
color: [f32; 4],
|
||||||
|
) -> Self {
|
||||||
|
let right = PAGE_WIDTH - RIGHT_MARGIN;
|
||||||
|
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0);
|
||||||
|
let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
|
||||||
|
let line_height = text.layout().line_height;
|
||||||
|
Self {
|
||||||
|
page_id,
|
||||||
|
original: None,
|
||||||
|
origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)),
|
||||||
|
axes: [glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
focus: true,
|
||||||
|
finish: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearbeitung des vorhandenen Textfelds `stroke`.
|
||||||
|
pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option<Self> {
|
||||||
|
let text = stroke.text.clone()?;
|
||||||
|
let corners = stroke.object_corners()?;
|
||||||
|
let (origin, axes) = frame_axes(&corners, text.box_size());
|
||||||
|
Some(Self {
|
||||||
|
page_id,
|
||||||
|
original: Some(stroke.clone()),
|
||||||
|
origin,
|
||||||
|
axes,
|
||||||
|
text,
|
||||||
|
color: stroke.color,
|
||||||
|
focus: true,
|
||||||
|
finish: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wurde gegenüber dem Ausgangszustand etwas geändert?
|
||||||
|
pub fn is_changed(&self) -> bool {
|
||||||
|
match &self.original {
|
||||||
|
Some(original) => {
|
||||||
|
original.text.as_ref() != Some(&self.text) || original.color != self.color
|
||||||
|
}
|
||||||
|
None => !self.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enthält das Feld nur Leerraum?
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.text.content.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage,
|
||||||
|
/// Drehung und Skalierung bleiben. `None` für ein leeres Feld.
|
||||||
|
pub fn to_stroke(&self, id: u64) -> Option<Stroke> {
|
||||||
|
if self.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let corners = frame_corners(self.origin, self.axes, self.text.box_size());
|
||||||
|
Some(Stroke::new_text(id, self.text.clone(), corners, self.color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige
|
||||||
|
/// gleich kräftig wirken).
|
||||||
|
const COVERAGE_GAMMA: f32 = 0.55;
|
||||||
|
|
||||||
|
/// Größte Kantenlänge einer Texttextur in Pixeln.
|
||||||
|
const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
||||||
|
/// Größte Pixelzahl einer Texttextur (64 MB RGBA).
|
||||||
|
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
||||||
|
|
||||||
|
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||||
|
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
||||||
|
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||||
|
let layout = text.layout();
|
||||||
|
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||||
|
let scale = px_per_unit
|
||||||
|
.min(MAX_TEXTURE_SIDE / size.x)
|
||||||
|
.min(MAX_TEXTURE_SIDE / size.y)
|
||||||
|
.min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt())
|
||||||
|
.max(1e-3);
|
||||||
|
let width = ((size.x * scale).ceil() as u32).max(1);
|
||||||
|
let height = ((size.y * scale).ceil() as u32).max(1);
|
||||||
|
|
||||||
|
let face = text.font.face();
|
||||||
|
// ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge
|
||||||
|
let px_scale = PxScale::from(text.size * scale * text.font.height_per_em());
|
||||||
|
let mut coverage = vec![0.0_f32; (width * height) as usize];
|
||||||
|
for (i, line) in layout.lines.iter().enumerate() {
|
||||||
|
let baseline = layout.baseline(i) * scale;
|
||||||
|
for &(id, x) in &line.glyphs {
|
||||||
|
let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline));
|
||||||
|
let Some(outlined) = face.outline_glyph(glyph) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let bounds = outlined.px_bounds();
|
||||||
|
outlined.draw(|gx, gy, c| {
|
||||||
|
let px = bounds.min.x as i64 + gx as i64;
|
||||||
|
let py = bounds.min.y as i64 + gy as i64;
|
||||||
|
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
|
||||||
|
let cell = &mut coverage[(py as u32 * width + px as u32) as usize];
|
||||||
|
*cell = (*cell + c).min(1.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
||||||
|
let alpha = color[3].clamp(0.0, 1.0);
|
||||||
|
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||||
|
for c in coverage {
|
||||||
|
pixels.extend_from_slice(&rgb);
|
||||||
|
// Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass
|
||||||
|
pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8);
|
||||||
|
}
|
||||||
|
RgbaImage {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample(content: &str, wrap_width: f32) -> TextBox {
|
||||||
|
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fonts_load() {
|
||||||
|
for font in [TextFont::Sans, TextFont::Mono] {
|
||||||
|
assert!(font.face().glyph_id('ä').0 != 0);
|
||||||
|
let factor = font.height_per_em();
|
||||||
|
assert!((1.0..1.4).contains(&factor), "{factor}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_layout_wraps_at_spaces() {
|
||||||
|
let text = sample("eins zwei drei vier fünf sechs", 400.0);
|
||||||
|
let layout = text.layout();
|
||||||
|
assert!(layout.lines.len() > 1);
|
||||||
|
for line in &layout.lines {
|
||||||
|
assert!(line.width <= 400.0 + 1e-3, "{line:?}");
|
||||||
|
assert!(!line.text.starts_with(' ') && !line.text.ends_with(' '));
|
||||||
|
}
|
||||||
|
let words: Vec<String> = layout.lines.iter().map(|l| l.text.clone()).collect();
|
||||||
|
assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_layout_breaks_long_words_and_keeps_newlines() {
|
||||||
|
let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0);
|
||||||
|
let layout = text.layout();
|
||||||
|
assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3));
|
||||||
|
let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect();
|
||||||
|
assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende");
|
||||||
|
// Leere Zeile bleibt erhalten
|
||||||
|
assert!(layout.lines.iter().any(|l| l.text.is_empty()));
|
||||||
|
assert_eq!(layout.lines.last().unwrap().text, "ende");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_text_is_one_line_high() {
|
||||||
|
let layout = sample("", 500.0).layout();
|
||||||
|
assert!((layout.height() - layout.line_height).abs() < 1e-3);
|
||||||
|
assert!(layout.line_height > 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tabs_become_spaces_and_indent_is_kept() {
|
||||||
|
let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0);
|
||||||
|
let line = &text.layout().lines[0];
|
||||||
|
assert_eq!(line.text, " x");
|
||||||
|
// Festbreitenschrift: x steht vier Zeichenbreiten weiter
|
||||||
|
let advance = line.glyphs[1].1;
|
||||||
|
assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_round_trip() {
|
||||||
|
let size = glam::Vec2::new(400.0, 120.0);
|
||||||
|
let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)];
|
||||||
|
let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size);
|
||||||
|
let (origin, back) = frame_axes(&corners, size);
|
||||||
|
assert_eq!(origin, glam::Vec2::new(100.0, 50.0));
|
||||||
|
assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rasterize_draws_ink_in_color() {
|
||||||
|
let text = sample("Hallo", 400.0);
|
||||||
|
let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0);
|
||||||
|
assert_eq!(image.width, 400);
|
||||||
|
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||||
|
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||||
|
assert!(opaque > 100, "{opaque}");
|
||||||
|
// Farbe ist sRGB-kodiert
|
||||||
|
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_editing_new_text_box() {
|
||||||
|
let edit = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(500.0, 800.0),
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3);
|
||||||
|
assert!(edit.origin.y < 800.0);
|
||||||
|
assert!(!edit.is_changed());
|
||||||
|
assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen");
|
||||||
|
|
||||||
|
// Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist
|
||||||
|
let edge = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(2050.0, 100.0),
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3);
|
||||||
|
assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_editing_keeps_transform_and_grows_with_text() {
|
||||||
|
let mut edit = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(400.0, 400.0),
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
edit.text.content = "eine Zeile".into();
|
||||||
|
let mut stroke = edit.to_stroke(5).unwrap();
|
||||||
|
assert!(stroke.is_text() && stroke.is_object());
|
||||||
|
stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5);
|
||||||
|
stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0));
|
||||||
|
let before = stroke.object_corners().unwrap();
|
||||||
|
|
||||||
|
let mut edit = TextEditing::of_stroke(1, &stroke).unwrap();
|
||||||
|
assert!(!edit.is_changed());
|
||||||
|
edit.text.content = "eine Zeile\nzwei Zeilen".into();
|
||||||
|
assert!(edit.is_changed());
|
||||||
|
let after = edit.to_stroke(5).unwrap().object_corners().unwrap();
|
||||||
|
// Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang
|
||||||
|
assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2);
|
||||||
|
let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]);
|
||||||
|
assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3);
|
||||||
|
assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rasterize_limits_texture_size() {
|
||||||
|
let text = sample("x", 2000.0);
|
||||||
|
let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0);
|
||||||
|
assert!(image.width as f32 <= MAX_TEXTURE_SIDE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ use crate::file_picker::{FilePicker, PickerKind};
|
|||||||
use crate::ruler::{Ruler, RulerHit};
|
use crate::ruler::{Ruler, RulerHit};
|
||||||
use crate::settings::{PressureCurve, SidebarSide};
|
use crate::settings::{PressureCurve, SidebarSide};
|
||||||
use crate::storage::NotebookSummary;
|
use crate::storage::NotebookSummary;
|
||||||
|
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
|
||||||
|
|
||||||
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
/// Aktives Werkzeug für das Zeichnen oder Bearbeiten.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -29,6 +30,8 @@ pub enum ActiveTool {
|
|||||||
Eraser,
|
Eraser,
|
||||||
Shape,
|
Shape,
|
||||||
Select,
|
Select,
|
||||||
|
/// Textfelder schreiben und bearbeiten
|
||||||
|
Text,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveTool {
|
impl ActiveTool {
|
||||||
@@ -39,6 +42,7 @@ impl ActiveTool {
|
|||||||
ActiveTool::Eraser => "Radierer (Eraser)",
|
ActiveTool::Eraser => "Radierer (Eraser)",
|
||||||
ActiveTool::Shape => "Formen (Shape)",
|
ActiveTool::Shape => "Formen (Shape)",
|
||||||
ActiveTool::Select => "Auswahl / Lasso (Select)",
|
ActiveTool::Select => "Auswahl / Lasso (Select)",
|
||||||
|
ActiveTool::Text => "Text (T)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +268,12 @@ pub struct UiState {
|
|||||||
pub adapt_ink_colors: bool,
|
pub adapt_ink_colors: bool,
|
||||||
/// Seiten hängen untereinander und werden durchgehend gescrollt
|
/// Seiten hängen untereinander und werden durchgehend gescrollt
|
||||||
pub continuous_scroll: bool,
|
pub continuous_scroll: bool,
|
||||||
|
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
|
||||||
|
pub text_color: [f32; 4],
|
||||||
|
pub text_size_pt: f32,
|
||||||
|
pub text_font: TextFont,
|
||||||
|
/// Laufende Bearbeitung eines Textfelds
|
||||||
|
pub text_edit: Option<TextEditing>,
|
||||||
/// Druckkurve des Stifts
|
/// Druckkurve des Stifts
|
||||||
pub pressure_curve: PressureCurve,
|
pub pressure_curve: PressureCurve,
|
||||||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||||
@@ -326,6 +336,10 @@ impl Default for UiState {
|
|||||||
paper_theme: PaperTheme::White,
|
paper_theme: PaperTheme::White,
|
||||||
adapt_ink_colors: true,
|
adapt_ink_colors: true,
|
||||||
continuous_scroll: true,
|
continuous_scroll: true,
|
||||||
|
text_color: PEN_COLOR_PRESETS[0].0,
|
||||||
|
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
|
||||||
|
text_font: TextFont::Sans,
|
||||||
|
text_edit: None,
|
||||||
pressure_curve: PressureCurve::default(),
|
pressure_curve: PressureCurve::default(),
|
||||||
last_raw_pressure: None,
|
last_raw_pressure: None,
|
||||||
file_picker: None,
|
file_picker: None,
|
||||||
@@ -422,9 +436,10 @@ impl UiState {
|
|||||||
/// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen
|
/// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen
|
||||||
/// des aktiven Werkzeugs in `max_height` passt.
|
/// des aktiven Werkzeugs in `max_height` passt.
|
||||||
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 {
|
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 {
|
||||||
let select = tool == ActiveTool::Select;
|
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
|
||||||
// Notizbücher, 5 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen)
|
// Notizbücher, 6 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen bzw.
|
||||||
let buttons = 12 + if select { 4 } else { 0 };
|
// Schriftknöpfe)
|
||||||
|
let buttons = 13 + if select { 4 } else { 0 };
|
||||||
let dividers = 5 + usize::from(select);
|
let dividers = 5 + usize::from(select);
|
||||||
let spacing = 3.0;
|
let spacing = 3.0;
|
||||||
let needed = |b: f32| {
|
let needed = |b: f32| {
|
||||||
@@ -456,6 +471,7 @@ impl UiState {
|
|||||||
match self.active_tool {
|
match self.active_tool {
|
||||||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
|
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
|
||||||
ActiveTool::Highlighter => self.highlighter_color,
|
ActiveTool::Highlighter => self.highlighter_color,
|
||||||
|
ActiveTool::Text => self.text_color,
|
||||||
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0],
|
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +479,9 @@ impl UiState {
|
|||||||
/// Gibt die aktuelle Strichbreite in mm zurück.
|
/// Gibt die aktuelle Strichbreite in mm zurück.
|
||||||
pub fn current_stroke_width_mm(&self) -> f32 {
|
pub fn current_stroke_width_mm(&self) -> f32 {
|
||||||
match self.active_tool {
|
match self.active_tool {
|
||||||
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_stroke_width_mm,
|
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => {
|
||||||
|
self.pen_stroke_width_mm
|
||||||
|
}
|
||||||
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
|
ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
|
||||||
ActiveTool::Eraser => 3.0,
|
ActiveTool::Eraser => 3.0,
|
||||||
}
|
}
|
||||||
@@ -951,7 +969,33 @@ pub fn page_thumbnail_shapes(
|
|||||||
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
|
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
|
||||||
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
|
||||||
|
|
||||||
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
|
// Textfelder: je Zeile ein Strich in Textfarbe auf Höhe der Kleinbuchstaben
|
||||||
|
for stroke in &page.strokes {
|
||||||
|
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ink = if adapt {
|
||||||
|
adapt_ink_color(stroke.color)
|
||||||
|
} else {
|
||||||
|
stroke.color
|
||||||
|
};
|
||||||
|
let color = color32_from(ink).gamma_multiply(0.7);
|
||||||
|
let layout = text.layout();
|
||||||
|
let (origin, [right, down]) = crate::text::frame_axes(&corners, text.box_size());
|
||||||
|
let width = (text.size * 0.45 * down.length() * scale).clamp(0.6, 6.0);
|
||||||
|
for (i, line) in layout.lines.iter().enumerate() {
|
||||||
|
if line.width <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = origin + down * (layout.baseline(i) - text.size * 0.25);
|
||||||
|
shapes.push(egui::Shape::line_segment(
|
||||||
|
[to_screen(start), to_screen(start + right * line.width)],
|
||||||
|
egui::Stroke::new(width, color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for stroke in page.strokes.iter().filter(|s| !s.is_object()) {
|
||||||
let ink = if adapt {
|
let ink = if adapt {
|
||||||
adapt_ink_color(stroke.color)
|
adapt_ink_color(stroke.color)
|
||||||
} else {
|
} else {
|
||||||
@@ -979,6 +1023,126 @@ pub fn page_thumbnail_shapes(
|
|||||||
shapes
|
shapes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Knöpfe für Schriftart und -größe des Text-Werkzeugs. Änderungen gelten auch für das
|
||||||
|
/// gerade bearbeitete Textfeld.
|
||||||
|
fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
|
||||||
|
let size = state.text_size_pt;
|
||||||
|
let smaller = TEXT_SIZES_PT
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.copied()
|
||||||
|
.find(|&s| s < size - 0.01);
|
||||||
|
let larger = TEXT_SIZES_PT.iter().copied().find(|&s| s > size + 0.01);
|
||||||
|
let smaller_tip = format!("Kleinere Schrift (jetzt {size} pt)");
|
||||||
|
let larger_tip = format!("Größere Schrift (jetzt {size} pt)");
|
||||||
|
enum Change {
|
||||||
|
Font(TextFont),
|
||||||
|
/// Neue Größe; `None`: keine kleinere bzw. größere mehr
|
||||||
|
Size(Option<f32>),
|
||||||
|
}
|
||||||
|
let buttons = [
|
||||||
|
(
|
||||||
|
SidebarIcon::Text("Aa"),
|
||||||
|
"Normale Schrift",
|
||||||
|
Change::Font(TextFont::Sans),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SidebarIcon::Text("{ }"),
|
||||||
|
"Code-Schrift (feste Zeichenbreite, Tab rückt ein)",
|
||||||
|
Change::Font(TextFont::Mono),
|
||||||
|
),
|
||||||
|
(SidebarIcon::Text("A−"), &smaller_tip, Change::Size(smaller)),
|
||||||
|
(SidebarIcon::Text("A+"), &larger_tip, Change::Size(larger)),
|
||||||
|
];
|
||||||
|
for (icon, tooltip, change) in buttons {
|
||||||
|
let (active, enabled) = match change {
|
||||||
|
Change::Font(font) => (state.text_font == font, true),
|
||||||
|
Change::Size(new_size) => (false, new_size.is_some()),
|
||||||
|
};
|
||||||
|
if !sidebar_button(ui, btn, icon, active, enabled, None, tooltip).clicked() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match change {
|
||||||
|
Change::Font(font) => state.text_font = font,
|
||||||
|
Change::Size(Some(new_size)) => {
|
||||||
|
state.text_size_pt = new_size;
|
||||||
|
state.set_export_message(format!("Schriftgröße {new_size} pt"));
|
||||||
|
}
|
||||||
|
Change::Size(None) => {}
|
||||||
|
}
|
||||||
|
if let Some(edit) = &mut state.text_edit {
|
||||||
|
edit.text.font = state.text_font;
|
||||||
|
edit.text.size = state.text_size_pt * crate::text::UNITS_PER_PT;
|
||||||
|
edit.focus = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
|
||||||
|
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
|
||||||
|
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
|
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
||||||
|
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let [sx, sy] = edit.axes.map(|a| a.length());
|
||||||
|
let font_size = (edit.text.size * sy * view.scale).clamp(2.0, 400.0);
|
||||||
|
// egui rundet die Glyphenhöhe auf ganze Pixel: die Umbruchbreite im selben Verhältnis
|
||||||
|
// anpassen, damit die Zeilen an denselben Stellen umbrechen wie auf dem Blatt
|
||||||
|
let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em();
|
||||||
|
let rounding = height_px.round().max(1.0) / height_px;
|
||||||
|
let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0);
|
||||||
|
let color = linear_color32(if adapt {
|
||||||
|
adapt_ink_color(edit.color)
|
||||||
|
} else {
|
||||||
|
edit.color
|
||||||
|
});
|
||||||
|
egui::Area::new(egui::Id::new("text_edit_area"))
|
||||||
|
.fixed_pos(view.to_screen(edit.origin))
|
||||||
|
.order(egui::Order::Middle)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::none()
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.0_f32,
|
||||||
|
egui::Color32::from_rgba_unmultiplied(70, 130, 230, 200),
|
||||||
|
))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let response = ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut edit.text.content)
|
||||||
|
.id(egui::Id::new("text_edit_field"))
|
||||||
|
.frame(false)
|
||||||
|
.margin(egui::vec2(0.0, 0.0))
|
||||||
|
.font(egui::FontId::new(font_size, edit.text.font.egui_family()))
|
||||||
|
.text_color(color)
|
||||||
|
.desired_width(width)
|
||||||
|
.desired_rows(1)
|
||||||
|
.lock_focus(true),
|
||||||
|
);
|
||||||
|
if edit.focus {
|
||||||
|
response.request_focus();
|
||||||
|
edit.focus = false;
|
||||||
|
}
|
||||||
|
// egui 0.27 zählt Enter in einem fokussierten Feld als Klick und fordert
|
||||||
|
// den Fokus neu an – dabei geht die Tab-Sperre verloren und Tab würde
|
||||||
|
// danach den Fokus wegnehmen statt einzurücken
|
||||||
|
ui.memory_mut(|m| {
|
||||||
|
m.set_focus_lock_filter(
|
||||||
|
response.id,
|
||||||
|
egui::EventFilter {
|
||||||
|
tab: true,
|
||||||
|
horizontal_arrows: true,
|
||||||
|
vertical_arrows: true,
|
||||||
|
escape: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||||
|
edit.finish = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB).
|
/// Linearer Farbwert (wie auf der GPU) als egui-Farbe (sRGB).
|
||||||
fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
||||||
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
||||||
@@ -1594,6 +1758,7 @@ pub fn render_ui(
|
|||||||
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();
|
state.screen_size = ctx.screen_rect().size();
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
|
text_edit_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;
|
||||||
@@ -1760,6 +1925,12 @@ pub fn render_ui(
|
|||||||
SidebarIcon::Lasso,
|
SidebarIcon::Lasso,
|
||||||
"Auswählen: verschieben, drehen, skalieren, kopieren (V)",
|
"Auswählen: verschieben, drehen, skalieren, kopieren (V)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
ActiveTool::Text,
|
||||||
|
SidebarIcon::Glyph("T"),
|
||||||
|
"Text (T): ins Blatt tippen und schreiben, vorhandenen Text \
|
||||||
|
antippen zum Ändern; Esc beendet",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
if sidebar_button(
|
if sidebar_button(
|
||||||
ui,
|
ui,
|
||||||
@@ -1806,6 +1977,9 @@ pub fn render_ui(
|
|||||||
&HIGHLIGHTER_COLOR_PRESETS[..],
|
&HIGHLIGHTER_COLOR_PRESETS[..],
|
||||||
state.highlighter_color,
|
state.highlighter_color,
|
||||||
)),
|
)),
|
||||||
|
ActiveTool::Text => {
|
||||||
|
Some((&PEN_COLOR_PRESETS[..], state.text_color))
|
||||||
|
}
|
||||||
ActiveTool::Eraser => None,
|
ActiveTool::Eraser => None,
|
||||||
};
|
};
|
||||||
// Fester Platz für die längste Palette, damit die Knöpfe darunter
|
// Fester Platz für die längste Palette, damit die Knöpfe darunter
|
||||||
@@ -1821,10 +1995,18 @@ pub fn render_ui(
|
|||||||
if color_circle_swatch(ui, color, is_sel, label, swatch)
|
if color_circle_swatch(ui, color, is_sel, label, swatch)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
if state.active_tool == ActiveTool::Highlighter {
|
match state.active_tool {
|
||||||
state.highlighter_color = color;
|
ActiveTool::Highlighter => {
|
||||||
} else {
|
state.highlighter_color = color
|
||||||
state.pen_color = color;
|
}
|
||||||
|
ActiveTool::Text => {
|
||||||
|
state.text_color = color;
|
||||||
|
if let Some(edit) = &mut state.text_edit {
|
||||||
|
edit.color = color;
|
||||||
|
edit.focus = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => state.pen_color = color,
|
||||||
}
|
}
|
||||||
actions.push(UiAction::SetColor(color));
|
actions.push(UiAction::SetColor(color));
|
||||||
}
|
}
|
||||||
@@ -1901,6 +2083,12 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Schrift des Text-Werkzeugs ---
|
||||||
|
if state.active_tool == ActiveTool::Text {
|
||||||
|
sidebar_divider(ui, btn);
|
||||||
|
text_style_buttons(ui, btn, state);
|
||||||
|
}
|
||||||
|
|
||||||
sidebar_divider(ui, btn);
|
sidebar_divider(ui, btn);
|
||||||
|
|
||||||
// --- Undo & Redo ---
|
// --- Undo & Redo ---
|
||||||
@@ -3561,6 +3749,79 @@ mod tests {
|
|||||||
ctx
|
ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_edit_overlay_takes_typing_and_escape() {
|
||||||
|
let notebook = Notebook::new();
|
||||||
|
let mut state = UiState::new();
|
||||||
|
state.active_tool = ActiveTool::Text;
|
||||||
|
state.page_view = Some(PageView {
|
||||||
|
origin: egui::pos2(300.0, 50.0),
|
||||||
|
scale: 0.3,
|
||||||
|
});
|
||||||
|
state.text_edit = Some(TextEditing::new_at(
|
||||||
|
notebook.current_page().id,
|
||||||
|
glam::Vec2::new(200.0, 300.0),
|
||||||
|
42.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
let frame = |state: &mut UiState, events: Vec<egui::Event>| {
|
||||||
|
ctx.begin_frame(egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::Pos2::ZERO,
|
||||||
|
egui::vec2(1280.0, 850.0),
|
||||||
|
)),
|
||||||
|
events,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||||
|
let _ = ctx.end_frame();
|
||||||
|
};
|
||||||
|
frame(&mut state, Vec::new());
|
||||||
|
assert!(ctx.wants_keyboard_input(), "Textfeld hat den Fokus");
|
||||||
|
frame(&mut state, vec![egui::Event::Text("Hallo".into())]);
|
||||||
|
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo");
|
||||||
|
// Zeilenumbruch und Tab (Einrücken) bleiben im Textfeld
|
||||||
|
frame(
|
||||||
|
&mut state,
|
||||||
|
vec![egui::Event::Key {
|
||||||
|
key: egui::Key::Enter,
|
||||||
|
physical_key: None,
|
||||||
|
pressed: true,
|
||||||
|
repeat: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
frame(
|
||||||
|
&mut state,
|
||||||
|
vec![egui::Event::Key {
|
||||||
|
key: egui::Key::Tab,
|
||||||
|
physical_key: None,
|
||||||
|
pressed: true,
|
||||||
|
repeat: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
frame(&mut state, vec![egui::Event::Text("x".into())]);
|
||||||
|
assert_eq!(state.text_edit.as_ref().unwrap().text.content, "Hallo\n\tx");
|
||||||
|
|
||||||
|
frame(
|
||||||
|
&mut state,
|
||||||
|
vec![egui::Event::Key {
|
||||||
|
key: egui::Key::Escape,
|
||||||
|
physical_key: None,
|
||||||
|
pressed: true,
|
||||||
|
repeat: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let edit = state.text_edit.as_ref().unwrap();
|
||||||
|
assert!(edit.finish);
|
||||||
|
assert_eq!(edit.text.content, "Hallo\n\tx");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sidebar_is_vertical_and_docked_to_chosen_side() {
|
fn test_sidebar_is_vertical_and_docked_to_chosen_side() {
|
||||||
let notebook = Notebook::new();
|
let notebook = Notebook::new();
|
||||||
@@ -3687,6 +3948,7 @@ mod tests {
|
|||||||
ActiveTool::Shape,
|
ActiveTool::Shape,
|
||||||
ActiveTool::Eraser,
|
ActiveTool::Eraser,
|
||||||
ActiveTool::Select,
|
ActiveTool::Select,
|
||||||
|
ActiveTool::Text,
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
found.contains(&tool),
|
found.contains(&tool),
|
||||||
@@ -3705,6 +3967,7 @@ mod tests {
|
|||||||
ActiveTool::Shape,
|
ActiveTool::Shape,
|
||||||
ActiveTool::Eraser,
|
ActiveTool::Eraser,
|
||||||
ActiveTool::Select,
|
ActiveTool::Select,
|
||||||
|
ActiveTool::Text,
|
||||||
] {
|
] {
|
||||||
let mut state = UiState::new();
|
let mut state = UiState::new();
|
||||||
state.active_tool = tool;
|
state.active_tool = tool;
|
||||||
|
|||||||
Reference in New Issue
Block a user