feat: Stifte, Seiten löschen, lückenlose Seiten, sauberer Textmarker (Issues #11, #12, #14, #15, #17, #19)

- #15: Koordinatensystem ohne Kerbe am Ursprung (Achsen beginnen eine halbe
  Linienbreite hinter dem Ursprung; im PDF ein Linienzug mit spitzer Ecke)
- #17: Jeder Strich deckt ein Pixel nur einmal (Stencil-Puffer), der
  Textmarker wird innerhalb eines Strichs nicht dunkler. Im PDF-Export ist der
  Textmarker jetzt halbtransparent statt deckend.
- #11: ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine Seite ein
- #12: Seite löschen im ☰-Menü mit Rückfrage, in der Sitzung wiederherstellbar
- #14: Seiten liegen lückenlos untereinander, eine dunkle Linie markiert die Grenze
- #19: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken
  (ui.json); Blätterpfeile nur noch in der Einzelseitenansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-08 15:55:32 +02:00
1 parent 904b36e5f7
commit 3890b6f182
14 files changed
+844 -80

No files matched your search

+135 -4
View File
@@ -166,6 +166,10 @@ pub struct CanvasState {
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
msaa_samples: u32,
msaa_view: Option<wgpu::TextureView>,
/// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente
/// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt
stencil_view: wgpu::TextureView,
stencil_reset_pipeline: wgpu::RenderPipeline,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device,
@@ -343,6 +347,7 @@ impl CanvasState {
1
};
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
let stencil_view = create_stencil_view(&device, &config, msaa_samples);
// 5. Shader & Bind Group Layout
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -423,7 +428,7 @@ impl CanvasState {
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
depth_stencil: Some(stroke_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -431,6 +436,34 @@ impl CanvasState {
multiview: None,
});
// Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen
let stencil_reset_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Stencil Reset Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_fullscreen",
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_none",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(stencil_reset()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None,
});
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Background TriangleList Pipeline"),
@@ -458,7 +491,7 @@ impl CanvasState {
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
depth_stencil: Some(passive_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -510,7 +543,7 @@ impl CanvasState {
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
depth_stencil: Some(passive_stencil()),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
@@ -549,6 +582,8 @@ impl CanvasState {
adapter,
msaa_samples,
msaa_view,
stencil_view,
stencil_reset_pipeline,
presentation_warned: false,
device,
queue,
@@ -698,6 +733,7 @@ impl CanvasState {
}
self.surface.configure(&self.device, &self.config);
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
self.recalculate_viewport();
}
}
@@ -1340,10 +1376,18 @@ impl CanvasState {
},
},
})],
depth_stencil_attachment: None,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.stencil_view,
depth_ops: None,
stencil_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0),
store: wgpu::StoreOp::Discard,
}),
}),
timestamp_writes: None,
occlusion_query_set: None,
});
let mut stencil_ref = 0;
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
for (i, neighbor) in self.neighbors.iter().enumerate() {
@@ -1362,6 +1406,7 @@ impl CanvasState {
render_pass.set_vertex_buffer(0, buffer.slice(..));
for range in &neighbor.stroke_ranges {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1393,6 +1438,7 @@ impl CanvasState {
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
for range in &self.committed_strokes {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1401,6 +1447,7 @@ impl CanvasState {
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(0..self.live_vertex_count, 0..1);
}
}
@@ -1437,6 +1484,20 @@ impl CanvasState {
Ok(())
}
/// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur
/// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer
/// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet.
fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) {
if *stencil_ref == u32::from(u8::MAX) {
pass.set_pipeline(&self.stencil_reset_pipeline);
pass.draw(0..3, 0..1);
pass.set_pipeline(&self.strip_pipeline);
*stencil_ref = 0;
}
*stencil_ref += 1;
pass.set_stencil_reference(*stencil_ref);
}
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
let dummy_delta = egui::TexturesDelta::default();
@@ -1477,6 +1538,76 @@ fn create_msaa_view(
})
}
/// Format des Stencil-Puffers der Zeichenfläche.
const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche).
fn create_stencil_view(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
samples: u32,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Stroke Stencil Buffer"),
size: wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: samples,
dimension: wgpu::TextureDimension::D2,
format: STENCIL_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor::default())
}
/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten.
fn stencil_state(
compare: wgpu::CompareFunction,
pass_op: wgpu::StencilOperation,
) -> wgpu::DepthStencilState {
let face = wgpu::StencilFaceState {
compare,
fail_op: wgpu::StencilOperation::Keep,
depth_fail_op: wgpu::StencilOperation::Keep,
pass_op,
};
wgpu::DepthStencilState {
format: STENCIL_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState {
front: face,
back: face,
read_mask: 0xff,
write_mask: 0xff,
},
bias: wgpu::DepthBiasState::default(),
}
}
/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt.
pub(crate) fn passive_stencil() -> wgpu::DepthStencilState {
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep)
}
/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren.
fn stroke_stencil() -> wgpu::DepthStencilState {
stencil_state(
wgpu::CompareFunction::NotEqual,
wgpu::StencilOperation::Replace,
)
}
/// Zurücksetzen: überall 0 eintragen.
fn stencil_reset() -> wgpu::DepthStencilState {
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero)
}
/// Zu rasterndes Textfeld.
struct TextSpec {
text: crate::text::TextBox,
+56
View File
@@ -1217,6 +1217,29 @@ impl Notebook {
index
}
/// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell
/// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt
/// stehen (`None`).
pub fn remove_current_page(&mut self) -> Option<Page> {
if self.pages.len() < 2 {
return None;
}
let page = self.pages.remove(self.current_page_index);
self.load_warnings.remove(&page.id);
self.current_page_index = self.current_page_index.min(self.pages.len() - 1);
Some(page)
}
/// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein
/// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück.
pub fn restore_page(&mut self, index: usize, page: Page) -> usize {
let index = index.min(self.pages.len());
self.next_page_id = self.next_page_id.max(page.id + 1);
self.pages.insert(index, page);
self.current_page_index = index;
index
}
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
@@ -1982,6 +2005,39 @@ mod tests {
assert!(new.pdf_background.is_none());
}
#[test]
fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new();
// Die einzige Seite bleibt
assert!(nb.remove_current_page().is_none());
nb.add_page(PageBackground::Lined);
nb.add_page(PageBackground::DotGrid);
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
// Mittlere Seite: danach ist die folgende aktuell
nb.current_page_index = 1;
let removed = nb.remove_current_page().unwrap();
assert_eq!(removed.id, ids[1]);
assert_eq!(
nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(),
[ids[0], ids[2]]
);
assert_eq!(nb.current_page_index, 1);
// Wiederherstellen an der alten Stelle
assert_eq!(nb.restore_page(1, removed), 1);
assert_eq!(nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(), ids);
assert_eq!(nb.current_page_index, 1);
// Letzte Seite: danach ist die vorige aktuell
nb.current_page_index = 2;
let last = nb.remove_current_page().unwrap();
assert_eq!(last.id, ids[2]);
assert_eq!(nb.current_page_index, 1);
// Neue Seiten bekommen keine schon vergebene Kennung
assert!(nb.next_page_id > ids[2]);
}
#[test]
fn test_append_page_when_writing_at_bottom_of_last_page() {
let mut nb = Notebook::new();
+104 -13
View File
@@ -180,7 +180,7 @@ pub fn export_notebook_to_bytes(
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
@@ -206,7 +206,7 @@ pub fn export_notebook_to_bytes(
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
@@ -247,6 +247,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
Ok(out)
}
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
/// gerundet); `None` für deckende Farben.
fn alpha_state_name(alpha: f32) -> Option<String> {
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
(percent < 100).then(|| format!("InkAlpha{percent}"))
}
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
/// unverändert.
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
let mut states: Vec<(String, f32)> = notebook
.pages
.iter()
.flat_map(|p| &p.strokes)
.filter(|s| !s.is_object())
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
.collect();
if states.is_empty() {
return Ok(bytes);
}
states.sort_by(|a, b| a.0.cmp(&b.0));
states.dedup_by(|a, b| a.0 == b.0);
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
for page_id in page_ids {
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
for (name, alpha) in &states {
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
let mut state = Dictionary::new();
state.set("Type", Object::Name(b"ExtGState".to_vec()));
state.set("ca", Object::Real(alpha));
state.set("CA", Object::Real(alpha));
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
/// Wörterbuch wird dafür zu einem eigenen Objekt.
fn dict_object_id(
doc: &mut printpdf::lopdf::Document,
owner: printpdf::lopdf::ObjectId,
key: &[u8],
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
if let Some(Object::Reference(id)) = entry {
return Ok(id);
}
let dict = match entry {
Some(Object::Dictionary(dict)) => dict,
_ => Dictionary::new(),
};
let id = doc.add_object(dict);
doc.get_object_mut(owner)?
.as_dict_mut()?
.set(key.to_vec(), Object::Reference(id));
Ok(id)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
@@ -468,6 +539,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
if stroke.is_object() {
continue;
}
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
// Stelle nur einmal, auch wo er sich selbst überlappt.
let alpha_state = alpha_state_name(stroke.color[3]);
if let Some(name) = &alpha_state {
layer.save_graphics_state();
layer.add_operation(printpdf::lopdf::content::Operation::new(
"gs",
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
));
}
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
} else {
@@ -480,6 +562,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
}
}
}
if alpha_state.is_some() {
layer.restore_graphics_state();
}
}
}
@@ -773,6 +858,10 @@ fn render_recognized_shape(
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
layer.set_outline_color(stroke_color.clone());
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
match shape {
RecognizedShape::Line { start, end } => {
@@ -966,17 +1055,19 @@ fn render_recognized_shape(
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
for end in [y_line_end, x_line_end] {
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(ex), Mm(ey)), false),
],
is_closed: false,
});
}
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
let points = [y_line_end, *origin, x_line_end]
.iter()
.map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(px), Mm(py)), false)
})
.collect();
layer.add_line(Line {
points,
is_closed: false,
});
// Pfeilspitzen als gefüllte Dreiecke
if *with_arrows {
+36 -7
View File
@@ -1924,16 +1924,21 @@ pub fn shape_to_mesh(
(*y_axis_end, *x_axis_end)
};
// Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die
// äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe)
let y_start = *origin - y_dir * half_w;
let x_start = *origin - x_dir * half_w;
// 1. Y-Achse (vom Ursprung zur Spitze)
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
vertices.push(InkVertex::new(
origin.x + y_norm.x,
origin.y + y_norm.y,
y_start.x + y_norm.x,
y_start.y + y_norm.y,
color,
));
vertices.push(InkVertex::new(
origin.x - y_norm.x,
origin.y - y_norm.y,
y_start.x - y_norm.x,
y_start.y - y_norm.y,
color,
));
vertices.push(InkVertex::new(
@@ -1950,15 +1955,15 @@ pub fn shape_to_mesh(
// Degenerierte Dreiecke für den Übergang zur X-Achse
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
let last_v = *vertices.last().unwrap();
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
let next_v = InkVertex::new(x_start.x + x_norm.x, x_start.y + x_norm.y, color);
vertices.push(last_v);
vertices.push(next_v);
// 2. X-Achse
vertices.push(next_v);
vertices.push(InkVertex::new(
origin.x - x_norm.x,
origin.y - x_norm.y,
x_start.x - x_norm.x,
x_start.y - x_norm.y,
color,
));
vertices.push(InkVertex::new(
@@ -2195,6 +2200,30 @@ mod tests {
}
}
#[test]
fn test_coordinate_system_corner_is_closed() {
// Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt
// der Achsenbänder (Issue #15)
let origin = Vec2::new(300.0, 900.0);
let shape = RecognizedShape::CoordinateSystem {
origin,
x_axis_end: Vec2::new(900.0, 900.0),
y_axis_end: Vec2::new(300.0, 300.0),
with_arrows: true,
};
let width = 30.0;
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
let corner = origin + Vec2::new(-width / 2.0, width / 2.0);
let nearest = vertices
.iter()
.map(|v| (Vec2::from(v.position) - corner).length())
.fold(f32::INFINITY, f32::min);
assert!(
nearest < 1e-3,
"äußere Ecke fehlt, nächster Punkt {nearest}"
);
}
#[test]
fn test_detect_triangle() {
let mut points = Vec::new();
+1 -1
View File
@@ -151,7 +151,7 @@ impl ImageLayer {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
depth_stencil: Some(crate::canvas::passive_stencil()),
multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
+59
View File
@@ -1058,6 +1058,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.pattern_color = ui_settings.pattern_color;
ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets;
ui_state.select_pen(ui_settings.active_pen);
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
@@ -1066,6 +1068,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.set_export_message(warning);
}
let mut selection_state = SelectionState::new();
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
// Sitzung
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
// Stifte geändert, aber noch nicht gespeichert
let mut pen_presets_dirty = false;
let mut clipboard = ClipboardBridge::new();
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
let mut pdf_renderer = {
@@ -2609,6 +2616,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern
updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
ui_state.restorable_page = last_deleted_page
.as_ref()
.filter(|(id, _, _)| *id == manifest.id)
.map(|(_, index, _)| index + 1);
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater);
canvas.ruler = ui_state.ruler;
@@ -2825,6 +2836,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
ui_state.set_export_message(format!("Seite {} eingefügt", index + 1));
}
UiAction::DeleteCurrentPage => {
let index = notebook.current_page_index;
if let Some(page) = notebook.remove_current_page() {
selection_state.clear();
let curr = notebook.current_page_index;
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut());
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
last_deleted_page = Some((manifest.id.clone(), index, page));
ui_state.set_export_message(format!(
"Seite {} gelöscht – im Menü ☰ wiederherstellbar",
index + 1
));
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
}
UiAction::RestoreDeletedPage => {
if let Some((id, index, page)) = last_deleted_page.take() {
if id == manifest.id {
selection_state.clear();
let index = notebook.restore_page(index, page);
canvas.sync_page(notebook.current_page_mut());
manifest.sync_from_notebook(&notebook);
// Die Seitendatei neu schreiben (sie kann inzwischen fehlen)
autosave.mark_page_dirty(notebook.current_page(), &manifest);
autosave.mark_manifest_dirty(&manifest);
ui_state.set_export_message(format!("Seite {} wiederhergestellt", index + 1));
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
} else {
last_deleted_page = Some((id, index, page));
}
}
}
UiAction::LoadAllPages => {
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
@@ -3216,6 +3263,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert
// wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers).
if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen {
pen_presets_dirty = true;
ui_settings.active_pen = ui_state.active_pen;
}
if pen_presets_dirty && !egui_ctx.is_using_pointer() {
pen_presets_dirty = false;
ui_settings.pen_presets = ui_state.pen_presets;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
let finish_text = ui_state.text_edit.as_ref().is_some_and(|edit| {
edit.finish
+52
View File
@@ -233,6 +233,58 @@ mod tests {
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_highlighter_is_translucent_in_export() {
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
let mut page = page_with_line();
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
for v in &mut page.strokes[0].vertices {
v.color = [0.0, 0.0, 0.0, 1.0];
}
let color = [1.0, 0.95, 0.1, 0.35];
let samples: Vec<StylusSample> = (0..=40)
.map(|i| {
let x = if i <= 20 { i } else { 40 - i } as f32;
StylusSample {
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
pressure: 0.6,
timestamp: i as f64 * 0.01,
}
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
color,
60.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(8, samples, vertices, color);
stroke.width = 60.0;
stroke.tool = crate::document::StrokeTool::Highlighter;
page.strokes.push(stroke);
let png = decode(
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
let line = at(700.0, 1000.0);
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
let tint = at(700.0, 1020.0);
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
assert!(
(tint[2] as f32 - expected_blue).abs() < 25.0,
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
);
}
#[test]
fn test_export_uses_pattern_color() {
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
+87
View File
@@ -73,6 +73,60 @@ impl Default for PressureCurve {
}
}
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PenPreset {
pub color: [f32; 4],
pub width_mm: f32,
}
impl PenPreset {
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self {
let mut color = fallback.color;
if self.color.iter().all(|c| c.is_finite()) {
for (out, c) in color.iter_mut().zip(self.color).take(3) {
*out = c.clamp(0.0, 1.0);
}
}
color[3] = 1.0;
let width_mm = if self.width_mm.is_finite() {
self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
} else {
fallback.width_mm
};
Self { color, width_mm }
}
}
/// Anzahl der Stifte in der Werkzeugleiste.
pub const PEN_PRESET_COUNT: usize = 4;
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
PenPreset {
color: [0.12, 0.12, 0.14, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.15, 0.35, 0.85, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.85, 0.20, 0.20, 1.0],
width_mm: 0.5,
},
PenPreset {
color: [0.18, 0.65, 0.35, 1.0],
width_mm: 0.8,
},
];
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -88,6 +142,9 @@ pub struct UiSettings {
pub pressure_curve: PressureCurve,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
pub continuous_scroll: bool,
/// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
}
impl Default for UiSettings {
@@ -99,6 +156,8 @@ impl Default for UiSettings {
pattern_color: PatternColor::default(),
pressure_curve: PressureCurve::default(),
continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
}
}
}
@@ -126,6 +185,10 @@ impl UiSettings {
Err(_) => Self::default(),
};
settings.pressure_curve = settings.pressure_curve.sanitized();
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
*preset = preset.sanitized(fallback);
}
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
settings
}
@@ -157,6 +220,11 @@ mod tests {
threshold: 0.1,
},
continuous_scroll: false,
pen_presets: [PenPreset {
color: [0.5, 0.2, 0.7, 1.0],
width_mm: 1.5,
}; PEN_PRESET_COUNT],
active_pen: 2,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
@@ -193,6 +261,25 @@ mod tests {
let curve = UiSettings::load_from(&path).pressure_curve;
assert_eq!(curve.exponent, 3.0);
assert_eq!(curve.threshold, 0.0);
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
assert_eq!(settings.active_pen, 0);
std::fs::write(
&path,
br#"{"active_pen": 9, "pen_presets": [
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
}
#[test]
+13
View File
@@ -48,6 +48,19 @@ fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
}
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@vertex
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
let y = f32(index & 2u) * 2.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
@fragment
fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
struct ImageVertexInput {
@location(0) position: vec2<f32>,
+272 -31
View File
@@ -18,7 +18,9 @@ use crate::document::{
};
use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit};
use crate::settings::{PressureCurve, SidebarSide};
use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_PEN_PRESETS, PEN_PRESET_COUNT,
};
use crate::storage::NotebookSummary;
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
@@ -152,6 +154,10 @@ pub enum UiAction {
PreviousPage,
NextPage,
AddPage,
/// Aktuelle Seite löschen (nach Rückfrage in der Oberfläche)
DeleteCurrentPage,
/// Zuletzt gelöschte Seite wiederherstellen
RestoreDeletedPage,
GoToPage(usize),
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
LoadAllPages,
@@ -256,6 +262,14 @@ pub struct UiState {
pub touch_draw_enabled: bool,
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
/// `pen_stroke_width_mm` gehören immer zum gewählten Stift
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize,
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
pub restorable_page: Option<usize>,
/// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>,
/// Fensterseite der Werkzeugleiste
@@ -333,6 +347,10 @@ impl Default for UiState {
update_modal_rect: None,
touch_draw_enabled: false,
last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
confirm_delete_page: false,
restorable_page: None,
shutdown_error: None,
sidebar_side: SidebarSide::Left,
is_more_menu_open: false,
@@ -372,6 +390,7 @@ impl UiState {
/// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit
/// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) {
self.confirm_delete_page = false;
self.is_pen_menu_open = false;
self.is_highlighter_menu_open = false;
self.is_more_menu_open = false;
@@ -439,12 +458,14 @@ impl UiState {
}
/// Größte Knopfgröße (40 px, schrittweise bis 28 px), bei der die Leiste mit allen Elementen
/// des aktiven Werkzeugs in `max_height` passt.
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32) -> f32 {
/// des aktiven Werkzeugs in `max_height` passt. `continuous`: durchgehende Ansicht (ohne
/// Blätterpfeile).
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 {
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
// Notizbücher, 6 Werkzeuge, Undo/Redo, 3× Seiten, Menü (+ 4 Auswahl-Aktionen bzw.
// Schriftknöpfe)
let buttons = 13 + if select { 4 } else { 0 };
// Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern:
// dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
let page_buttons = if continuous { 2 } else { 4 };
let buttons = 10 + page_buttons + if select { 4 } else { 0 };
let dividers = 5 + usize::from(select);
let spacing = 3.0;
let needed = |b: f32| {
@@ -471,6 +492,29 @@ impl UiState {
Self::default()
}
/// Wählt Stift `index`: Farbe und Stärke kommen aus seiner Vorlage.
pub fn select_pen(&mut self, index: usize) {
self.active_pen = index.min(PEN_PRESET_COUNT - 1);
let preset = self.pen_presets[self.active_pen];
self.pen_color = preset.color;
self.pen_stroke_width_mm = preset.width_mm;
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts;
/// `true`, wenn sie sich geändert hat (dann speichern).
pub fn sync_pen_preset(&mut self) -> bool {
let current = PenPreset {
color: self.pen_color,
width_mm: self.pen_stroke_width_mm,
};
let preset = &mut self.pen_presets[self.active_pen.min(PEN_PRESET_COUNT - 1)];
if *preset == current {
return false;
}
*preset = current;
true
}
/// Gibt die aktuelle Farbe des gewählten Werkzeugs zurück.
pub fn current_color(&self) -> [f32; 4] {
match self.active_tool {
@@ -886,6 +930,50 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str
}
/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser.
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist.
fn pen_preset_swatch(
ui: &mut egui::Ui,
preset: PenPreset,
is_selected: bool,
tooltip: &str,
size: f32,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
if ui.is_rect_visible(rect) {
let paper = rect.shrink(2.0);
ui.painter().rect_filled(
paper,
egui::Rounding::same(5.0),
egui::Color32::from_gray(245),
);
let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
let width = (1.0 + preset.width_mm * 2.5).clamp(1.5, paper.height() * 0.35);
let inset = 3.0 + width / 2.0;
ui.painter().line_segment(
[
egui::pos2(paper.left() + inset, paper.bottom() - inset),
egui::pos2(paper.right() - inset, paper.top() + inset),
],
egui::Stroke::new(width, egui::Color32::from_rgb(r, g, b)),
);
if is_selected {
ui.painter().rect_stroke(
rect.shrink(0.5),
egui::Rounding::same(7.0),
egui::Stroke::new(2.0_f32, egui::Color32::WHITE),
);
} else if response.hovered() {
ui.painter().rect_stroke(
rect.shrink(1.0),
egui::Rounding::same(7.0),
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(150)),
);
}
}
response.on_hover_text(tooltip)
}
fn color_circle_swatch(
ui: &mut egui::Ui,
color: [f32; 4],
@@ -1414,6 +1502,98 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
save.then_some(state.pressure_curve)
}
/// „Seite löschen“ mit Rückfrage im ☰-Menü und „Gelöschte Seite wiederherstellen“.
fn delete_page_buttons(
ui: &mut egui::Ui,
state: &mut UiState,
notebook: &Notebook,
actions: &mut Vec<UiAction>,
) {
let number = notebook.current_page_index + 1;
if state.confirm_delete_page {
ui.label(
egui::RichText::new(format!("Seite {number} mit allem Inhalt löschen?"))
.color(egui::Color32::from_rgb(255, 170, 160)),
);
ui.horizontal(|ui| {
let half = (ui.available_width() - ui.spacing().item_spacing.x) / 2.0;
if ui
.add_sized(
[half, 30.0],
egui::Button::new(egui::RichText::new("Löschen").color(egui::Color32::WHITE))
.fill(egui::Color32::from_rgb(170, 50, 45)),
)
.clicked()
{
actions.push(UiAction::DeleteCurrentPage);
state.confirm_delete_page = false;
}
if ui
.add_sized([half, 30.0], egui::Button::new("Abbrechen"))
.clicked()
{
state.confirm_delete_page = false;
}
});
} else {
let only_page = notebook.pages.len() < 2;
let response = ui
.add_enabled_ui(!only_page, |ui| {
ui.add_sized(
[ui.available_width(), 30.0],
egui::Button::new(format!("🗑 Seite {number} löschen …")),
)
})
.inner
.on_hover_text("Löscht die aktuelle Seite; danach im Menü wiederherstellbar")
.on_disabled_hover_text(
"Die einzige Seite eines Notizbuchs kann nicht gelöscht werden",
);
if response.clicked() {
state.confirm_delete_page = true;
}
}
if let Some(number) = state.restorable_page {
if ui
.add_sized(
[ui.available_width(), 30.0],
egui::Button::new(format!("⟲ Gelöschte Seite {number} wiederherstellen")),
)
.clicked()
{
actions.push(UiAction::RestoreDeletedPage);
}
}
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
return;
};
let stack = notebook.page_stack();
let screen = ctx.screen_rect();
let color = if state.paper_theme.is_dark() {
egui::Color32::from_white_alpha(140)
} else {
egui::Color32::from_black_alpha(200)
};
let painter = ctx.layer_painter(egui::LayerId::background());
for i in 1..stack.len() {
let y = stack.origins[i].y;
let left = stack.origins[i - 1].x.min(stack.origins[i].x);
let right = (stack.origins[i - 1].x + stack.sizes[i - 1].x)
.max(stack.origins[i].x + stack.sizes[i].x);
let a = view.to_screen(glam::Vec2::new(left, y));
let b = view.to_screen(glam::Vec2::new(right, y));
if a.y < screen.top() - 1.0 || a.y > screen.bottom() + 1.0 {
continue;
}
painter.line_segment([a, b], egui::Stroke::new(1.5_f32, color));
}
}
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
@@ -1815,6 +1995,7 @@ pub fn render_ui(
) -> Vec<UiAction> {
let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
@@ -1840,7 +2021,7 @@ pub fn render_ui(
let max_height = (ctx.screen_rect().height() - 2.0 * UiState::SIDEBAR_MARGIN - 12.0).max(120.0);
// Größe nach dem höchsten Fall (Auswahl mit Aktionen), damit Knöpfe beim
// Werkzeugwechsel weder schrumpfen noch verrutschen
let btn = UiState::sidebar_button_size(ActiveTool::Select, max_height);
let btn = UiState::sidebar_button_size(ActiveTool::Select, max_height, state.continuous_scroll);
let area_response = egui::Area::new(egui::Id::new("floating_toolbar_area"))
.anchor(anchor, offset)
@@ -2025,10 +2206,12 @@ pub fn render_ui(
state.toggle_ruler();
}
// --- Farben des aktiven Werkzeugs ---
// --- Stifte bzw. Farben des aktiven Werkzeugs ---
let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape);
let palette: Option<(&[ColorPreset], [f32; 4])> =
match state.active_tool {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => {
ActiveTool::Pen | ActiveTool::Shape => None,
ActiveTool::Select => {
Some((&PEN_COLOR_PRESETS[..], state.pen_color))
}
ActiveTool::Highlighter => Some((
@@ -2044,7 +2227,46 @@ pub fn render_ui(
// beim Werkzeugwechsel nicht springen
sidebar_divider(ui, btn);
let swatch = UiState::sidebar_swatch_size(btn);
let shown = palette.map_or(0, |(presets, _)| presets.len());
let mut shown = palette.map_or(0, |(presets, _)| presets.len());
if pens {
shown = PEN_PRESET_COUNT;
for i in 0..PEN_PRESET_COUNT {
let preset = state.pen_presets[i];
let selected = i == state.active_pen;
let tooltip = if selected {
format!(
"Stift {} ({:.2} mm) – erneut tippen für Farbe & Stärke; \
Änderungen merkt sich der Stift",
i + 1,
preset.width_mm
)
} else {
format!("Stift {} ({:.2} mm)", i + 1, preset.width_mm)
};
let response =
pen_preset_swatch(ui, preset, selected, &tooltip, swatch);
if selected {
// Die Einstellungen erscheinen neben dem gewählten Stift
pen_btn_rect = Some(response.rect);
}
if response.clicked() {
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
if selected {
let open = !state.is_pen_menu_open;
state.close_overlays();
state.is_pen_menu_open = open;
} else {
state.is_pen_menu_open = false;
state.select_pen(i);
actions.push(UiAction::SetColor(state.pen_color));
actions.push(UiAction::SetStrokeWidthMm(
state.pen_stroke_width_mm,
));
}
}
}
}
if let Some((presets, current)) = palette {
for &(color, label) in presets {
let is_sel = (current[0] - color[0]).abs() < 0.05
@@ -2064,7 +2286,8 @@ pub fn render_ui(
edit.focus = true;
}
}
_ => state.pen_color = color,
// Auswahl umfärben, der Stift bleibt
_ => {}
}
actions.push(UiAction::SetColor(color));
}
@@ -2181,16 +2404,20 @@ pub fn render_ui(
sidebar_divider(ui, btn);
// --- Seitennavigation ---
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏶"),
false,
notebook.current_page_index > 0,
None,
"Vorherige Seite (Bild auf)",
)
.clicked()
// In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt
// es nur in der Einzelseitenansicht
let paging = !state.continuous_scroll;
if paging
&& sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏶"),
false,
notebook.current_page_index > 0,
None,
"Vorherige Seite (Bild auf)",
)
.clicked()
{
actions.push(UiAction::PreviousPage);
}
@@ -2214,23 +2441,33 @@ pub fn render_ui(
state.close_overlays();
state.show_page_jump_popup = open;
}
let is_last = notebook.current_page_index + 1 >= notebook.pages.len();
if paging
&& sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏷"),
false,
notebook.current_page_index + 1 < notebook.pages.len(),
None,
"Nächste Seite (Bild ab)",
)
.clicked()
{
actions.push(UiAction::NextPage);
}
if sidebar_button(
ui,
btn,
SidebarIcon::Glyph("⏷"),
SidebarIcon::Glyph("➕"),
false,
true,
None,
if is_last {
"Neue Seite anhängen (Bild ab / N)"
} else {
"Nächste Seite (Bild ab / N)"
},
"Neue Seite nach dieser einfügen",
)
.clicked()
{
actions.push(UiAction::NextPage);
state.close_overlays();
actions.push(UiAction::AddPage);
}
sidebar_divider(ui, btn);
@@ -2303,7 +2540,7 @@ pub fn render_ui(
// Titelzeile mit Schließen-Button
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Stift-Konfiguration")
egui::RichText::new(format!("Stift {}", state.active_pen + 1))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
@@ -2625,6 +2862,7 @@ pub fn render_ui(
{
actions.push(UiAction::AddPage);
}
delete_page_buttons(ui, state, notebook, &mut actions);
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)"))
.on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")
@@ -2978,6 +3216,9 @@ pub fn render_ui(
}
state.popover_rect = popover_rect;
if !state.is_more_menu_open {
state.confirm_delete_page = false;
}
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open
@@ -4151,7 +4392,7 @@ mod tests {
}
// Große Fenster behalten die volle Knopfgröße
assert_eq!(
UiState::sidebar_button_size(ActiveTool::Select, 1000.0),
UiState::sidebar_button_size(ActiveTool::Select, 1000.0, true),
40.0
);
}
+18 -19
View File
@@ -3,20 +3,18 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use crate::document::{PageViewport, UNITS_PER_MM};
use crate::document::PageViewport;
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 10.0;
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// aktuellen Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct PageStack {
@@ -33,11 +31,11 @@ impl PageStack {
let mut origins = vec![Vec2::ZERO; sizes.len()];
let x = |i: usize| (width - sizes[i].x) / 2.0;
for i in current + 1..sizes.len() {
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
let y = origins[i - 1].y + sizes[i - 1].y;
origins[i] = Vec2::new(x(i), y);
}
for i in (0..current).rev() {
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
let y = origins[i + 1].y - sizes[i].y;
origins[i] = Vec2::new(x(i), y);
}
Self {
@@ -55,7 +53,7 @@ impl PageStack {
self.sizes.is_empty()
}
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
pub fn page_at(&self, y: f32) -> Option<usize> {
// Seiten liegen nach Index sortiert untereinander
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
@@ -312,7 +310,7 @@ mod tests {
use super::*;
const A4: Vec2 = crate::document::A4_SIZE;
const PITCH: f32 = A4.y + PAGE_GAP;
const PITCH: f32 = A4.y;
fn a4_stack(count: usize, current: usize) -> PageStack {
PageStack::new(vec![A4; count], current)
@@ -322,10 +320,11 @@ mod tests {
fn test_continuous_layout() {
let stack = a4_stack(5, 1);
assert_eq!(stack.page_at(100.0), Some(1));
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(stack.page_at(-5.0), None);
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
assert_eq!(stack.page_at(-5.0), Some(0));
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
@@ -339,7 +338,7 @@ mod tests {
.is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
vec![2, 4]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
@@ -352,13 +351,13 @@ mod tests {
fn test_stack_of_different_page_sizes() {
let slide = Vec2::new(2970.0, 1671.0);
let stack = PageStack::new(vec![slide, A4, slide], 1);
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
let (min, max) = stack.bounds();
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
assert_eq!(min, Vec2::new(-435.0, -slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
}
#[test]