fix: palm rejection, eraser sizes, PDF thumbnails, even polygon strokes (Issues #37-#41)
- #41: snapped triangles, rectangles and polygons keep the full stroke width on every side; corners are mitered, sharp ones bevelled (the old corner offset thinned the edges) - #38: four eraser sizes, cycled by a button below the eraser modes and remembered in ui.json - #37: a selection made with the pen's select button can be moved with the bare pen - #39: fingers resting on the screen when the pen comes near, or touching it while the pen is near (and 0.7 s after), are ignored until lifted; the vendored winit ignores touches on the title bar meanwhile - #40: the page picker shows imported PDF pages, rendered small in the background Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31a57dc7a7
commit
2a06e2604a
11 files changed
+409
-45
No files matched your search
+78
-13
@@ -1801,6 +1801,52 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||
Some((sum_sq / points.len() as f32).sqrt())
|
||||
}
|
||||
|
||||
/// Longest miter of a polygon corner, in half stroke widths; sharper corners are bevelled.
|
||||
const MITER_LIMIT: f32 = 2.0;
|
||||
|
||||
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
|
||||
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
|
||||
/// edges keep the full half width `half_w` up to the corner (Issue #41): mild corners get one
|
||||
/// mitered pair, sharp ones a bevel from two pairs that share the inner point.
|
||||
fn polygon_corner_join(
|
||||
curr: Vec2,
|
||||
prev: Vec2,
|
||||
next: Vec2,
|
||||
n_prev: Vec2,
|
||||
n_next: Vec2,
|
||||
half_w: f32,
|
||||
) -> Vec<[Vec2; 2]> {
|
||||
let bisector = (n_prev + n_next).normalize_or_zero();
|
||||
let cos_half = bisector.dot(n_prev);
|
||||
if bisector == Vec2::ZERO || cos_half <= 1e-3 {
|
||||
// Edge turns back on itself
|
||||
return vec![[curr + n_prev * half_w, curr - n_prev * half_w]];
|
||||
}
|
||||
let miter = half_w / cos_half;
|
||||
if miter <= half_w * MITER_LIMIT {
|
||||
let offset = bisector * miter;
|
||||
return vec![[curr + offset, curr - offset]];
|
||||
}
|
||||
// The inner point may not reach past the shorter adjacent edge
|
||||
let shortest = (curr - prev).length().min((next - curr).length());
|
||||
let inner_len = miter.min(shortest.max(half_w));
|
||||
// Turning towards the left normal: the outer side is on the right
|
||||
let turns_left = (next - curr).dot(n_prev) > 0.0;
|
||||
if turns_left {
|
||||
let inner = curr + bisector * inner_len;
|
||||
vec![
|
||||
[inner, curr - n_prev * half_w],
|
||||
[inner, curr - n_next * half_w],
|
||||
]
|
||||
} else {
|
||||
let inner = curr - bisector * inner_len;
|
||||
vec![
|
||||
[curr + n_prev * half_w, inner],
|
||||
[curr + n_next * half_w, inner],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
|
||||
pub fn closed_polygon_to_mesh(
|
||||
corners: &[Vec2],
|
||||
@@ -1812,7 +1858,7 @@ pub fn closed_polygon_to_mesh(
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
let mut vertices = Vec::with_capacity((n + 1) * 2);
|
||||
let mut vertices = Vec::with_capacity((2 * n + 1) * 2);
|
||||
let mut samples = Vec::with_capacity(n * 8);
|
||||
|
||||
for i in 0..n {
|
||||
@@ -1825,18 +1871,10 @@ pub fn closed_polygon_to_mesh(
|
||||
|
||||
let n_prev = Vec2::new(-d_prev.y, d_prev.x);
|
||||
let n_next = Vec2::new(-d_next.y, d_next.x);
|
||||
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
|
||||
|
||||
vertices.push(InkVertex::new(
|
||||
curr.x + corner_norm.x,
|
||||
curr.y + corner_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
curr.x - corner_norm.x,
|
||||
curr.y - corner_norm.y,
|
||||
color,
|
||||
));
|
||||
for [left, right] in polygon_corner_join(curr, prev, next, n_prev, n_next, half_w) {
|
||||
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||
}
|
||||
|
||||
let edge_len = (next - curr).length();
|
||||
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
|
||||
@@ -2481,6 +2519,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_polygon_edges_keep_full_width() {
|
||||
// Right and acute corners: no outline vertex comes closer than half a stroke width to
|
||||
// the outline, so all three sides look equally thick (Issue #41); mitered corners
|
||||
// reach at most MITER_LIMIT half widths out
|
||||
let half_w = 10.0;
|
||||
let corners = [
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(0.0, 300.0),
|
||||
Vec2::new(300.0, 300.0),
|
||||
];
|
||||
let (_, vertices) = closed_polygon_to_mesh(&corners, half_w, [0.0, 0.0, 0.0, 1.0]);
|
||||
assert!(vertices.len() > 2 * corners.len());
|
||||
for v in &vertices {
|
||||
let p = Vec2::new(v.position[0], v.position[1]);
|
||||
let nearest = (0..3)
|
||||
.map(|i| {
|
||||
crate::document::point_to_segment_distance(p, corners[i], corners[(i + 1) % 3])
|
||||
})
|
||||
.fold(f32::MAX, f32::min);
|
||||
assert!(
|
||||
nearest > half_w - 0.01 && nearest < half_w * MITER_LIMIT + 0.01,
|
||||
"{p:?} is {nearest} from the outline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_triangle() {
|
||||
let mut points = Vec::new();
|
||||
|
||||
+3
-1
@@ -255,8 +255,10 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Quell-PDF fehlt", "Source PDF missing"),
|
||||
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
||||
("Radieren", "Erase"),
|
||||
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"),
|
||||
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
|
||||
("Radierer (Eraser)", "Eraser"),
|
||||
("Radiergröße {size} mm", "Eraser size {size} mm"),
|
||||
("Radiergröße {size} mm – tippen für die nächste Größe", "Eraser size {size} mm – tap for the next size"),
|
||||
("Raute (Rhombus)", "Rhombus"),
|
||||
("Rechteck", "Rectangle"),
|
||||
("Rechts", "Right"),
|
||||
|
||||
+131
-9
@@ -43,6 +43,9 @@ use ink_notes::updater::UpdaterController;
|
||||
|
||||
/// Kennung der App (Name der .desktop-Datei und des Symbols).
|
||||
const APP_ID: &str = "eu.pansi.ink-notes";
|
||||
/// After the pen leaves the screen, new touches still count as the writing hand for this long
|
||||
/// (Issue #39).
|
||||
const PALM_GRACE: Duration = Duration::from_millis(700);
|
||||
|
||||
/// Fenstersymbol aus dem eingebauten App-Symbol, auf 64 × 64 Pixel verkleinert: In voller
|
||||
/// Größe (256 × 256) überschreitet es die Größe einer einzelnen X11-Anfrage und fehlt dann.
|
||||
@@ -1350,6 +1353,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.continuous_scroll = ui_settings.continuous_scroll;
|
||||
ui_state.pen_presets = ui_settings.pen_presets;
|
||||
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
|
||||
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
|
||||
ui_state.select_pen(ui_settings.active_pen);
|
||||
ui_state.highlighter_colors = ui_settings.highlighter_colors;
|
||||
ui_state.select_highlighter_color(ui_settings.active_highlighter);
|
||||
@@ -1437,6 +1441,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Stift zieht mit dem Auswahlwerkzeug (Rahmen, Verschieben, Drehen, Skalieren)
|
||||
let mut is_stylus_selecting = false;
|
||||
let mut is_stylus_in_proximity = false;
|
||||
// Palm rejection (Issue #39): fingers that rest on the screen when the pen comes near, or
|
||||
// that touch it while the pen is near, are ignored until they are lifted
|
||||
let mut palm_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
let mut stylus_left_at: Option<Instant> = None;
|
||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||
// Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit
|
||||
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
|
||||
@@ -1465,7 +1473,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut stroke_snapped_shape: Option<RecognizedShape> = None;
|
||||
|
||||
// Radier-Parameter
|
||||
let eraser_radius = 30.0_f32; // 3 mm in DIN-A4-Einheiten
|
||||
let stroke_max_radius = 12.0_f32;
|
||||
|
||||
update_window_title(
|
||||
@@ -1589,6 +1596,46 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tool_is_hardware_eraser =
|
||||
tool.tool_type == Some(octotablet::tool::Type::Eraser);
|
||||
|
||||
let pen_arrives = !is_stylus_in_proximity
|
||||
&& matches!(
|
||||
event,
|
||||
octotablet::events::ToolEvent::In { .. }
|
||||
| octotablet::events::ToolEvent::Pose(_)
|
||||
| octotablet::events::ToolEvent::Down
|
||||
);
|
||||
if pen_arrives {
|
||||
// Fingers already on the screen are most likely the writing hand:
|
||||
// undo what they started and ignore them until lifted
|
||||
if debug_fling && !active_touches.is_empty() {
|
||||
eprintln!("[Nachgleiten] Stift kommt: {} Finger als Handballen ignoriert", active_touches.len());
|
||||
}
|
||||
palm_touch_ids.extend(active_touches.drain().map(|(id, _)| id));
|
||||
long_press.cancel();
|
||||
if is_touch_drawing {
|
||||
is_touch_drawing = false;
|
||||
is_drawing = false;
|
||||
canvas.cancel_stroke();
|
||||
}
|
||||
if is_touch_spacing {
|
||||
is_touch_spacing = false;
|
||||
ui_state.space_drag = None;
|
||||
}
|
||||
if selection_state.drag_mode != DragMode::None && !is_stylus_selecting {
|
||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas);
|
||||
}
|
||||
is_erasing = false;
|
||||
is_touch_panning = false;
|
||||
is_pinch_gesturing = false;
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
stylus_left_at = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
winit::platform::wayland::ignore_decoration_touches_until(Some(
|
||||
Instant::now() + Duration::from_secs(24 * 3600),
|
||||
));
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
match event {
|
||||
octotablet::events::ToolEvent::In { .. } => {
|
||||
is_stylus_in_proximity = true;
|
||||
@@ -1596,6 +1643,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
octotablet::events::ToolEvent::Out
|
||||
| octotablet::events::ToolEvent::Removed => {
|
||||
if is_stylus_in_proximity {
|
||||
stylus_left_at = Some(Instant::now());
|
||||
#[cfg(target_os = "linux")]
|
||||
winit::platform::wayland::ignore_decoration_touches_until(Some(
|
||||
Instant::now() + PALM_GRACE,
|
||||
));
|
||||
}
|
||||
is_stylus_in_proximity = false;
|
||||
if stylus_ui_capture {
|
||||
stylus_ui_capture = false;
|
||||
@@ -1724,7 +1778,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
focus_page_at!((sx, sy));
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
if notebook.erase_at(page_pt,
|
||||
eraser_radius,
|
||||
ui_state.eraser_radius(),
|
||||
stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -1801,6 +1855,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(StylusAction::Pen) => Some(ActiveTool::Pen),
|
||||
_ => None,
|
||||
};
|
||||
// A selection made with the select button stays grabbable with
|
||||
// the bare pen; elsewhere the pen draws and the selection ends
|
||||
// (Issue #37)
|
||||
let mut button_tool = button_tool;
|
||||
if button_action.is_none()
|
||||
&& !tool_is_hardware_eraser
|
||||
&& (ui_state.active_tool.is_drawing()
|
||||
|| ui_state.active_tool == ActiveTool::Shape)
|
||||
&& !selection_state.is_empty()
|
||||
{
|
||||
let hit = selection_state.hit_test_handles(
|
||||
notebook.current_page(),
|
||||
&canvas,
|
||||
Vec2::new(sx, sy),
|
||||
egui_ctx.pixels_per_point(),
|
||||
);
|
||||
if matches!(hit, SelectionHandleHit::None) {
|
||||
selection_state.clear();
|
||||
} else {
|
||||
button_tool = Some(ActiveTool::Select);
|
||||
}
|
||||
}
|
||||
if let Some(tool) = button_tool
|
||||
.filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool)
|
||||
{
|
||||
@@ -1829,7 +1905,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_erasing = true;
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
if notebook.erase_at(page_pt,
|
||||
eraser_radius,
|
||||
ui_state.eraser_radius(),
|
||||
stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
@@ -2118,6 +2194,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_mouse_drawing = false;
|
||||
active_touches.clear();
|
||||
ui_touch_ids.clear();
|
||||
palm_touch_ids.clear();
|
||||
stylus_ui_capture = false;
|
||||
is_pinch_gesturing = false;
|
||||
pinch_prev_distance = None;
|
||||
@@ -2605,7 +2682,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_erasing = true;
|
||||
let (sx, sy) = cursor_screen_pos;
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2753,7 +2830,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else if is_erasing {
|
||||
focus_page_at!((sx, sy));
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -2846,8 +2923,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
|
||||
|
||||
// Hardware-Palm-Rejection: Wenn der Stylus in Proximity ist oder zeichnet, Touch ignorieren
|
||||
if is_stylus_drawing || is_stylus_in_proximity {
|
||||
// Palm rejection: a touch that starts while the pen is near the screen (or
|
||||
// was just lifted away) belongs to the hand and is ignored until lifted
|
||||
let pen_near = is_stylus_drawing
|
||||
|| is_stylus_in_proximity
|
||||
|| stylus_left_at.is_some_and(|t| t.elapsed() < PALM_GRACE);
|
||||
if touch.phase == TouchPhase::Started && pen_near {
|
||||
palm_touch_ids.insert(touch.id);
|
||||
}
|
||||
if palm_touch_ids.contains(&touch.id) {
|
||||
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
|
||||
palm_touch_ids.remove(&touch.id);
|
||||
}
|
||||
if debug_fling && touch.phase != TouchPhase::Moved {
|
||||
eprintln!("[Nachgleiten] Finger ignoriert ({:?}): Stift in der Nähe", touch.phase);
|
||||
}
|
||||
@@ -2955,7 +3042,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if is_eraser_action {
|
||||
is_erasing = true;
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3048,7 +3135,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else if is_erasing {
|
||||
focus_page_at!((tx, ty));
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
|
||||
if notebook.erase_at(page_pt, ui_state.eraser_radius(), stroke_max_radius, ui_state.eraser_mode) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
@@ -3237,6 +3324,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// 4. UI-Aktionen verarbeiten
|
||||
let mut pdf_thumbnails_wanted = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
UiAction::SetTool(tool) => {
|
||||
@@ -3486,6 +3574,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
UiAction::RequestPdfThumbnails(pdfs) => {
|
||||
pdf_thumbnails_wanted = pdfs;
|
||||
}
|
||||
UiAction::LoadAllPages => {
|
||||
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
|
||||
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
|
||||
@@ -4020,6 +4111,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
updated.active_highlighter = ui_state.active_highlighter;
|
||||
updated.text_colors = ui_state.text_colors;
|
||||
updated.active_text_color = ui_state.active_text_color;
|
||||
updated.eraser_radius_mm = ui_state.eraser_radius_mm;
|
||||
if updated != ui_settings {
|
||||
ui_settings = updated;
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
@@ -4098,6 +4190,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Scrollen), verwirft end_frame, bevor sie gerendert werden
|
||||
pdf_renderer.begin_frame();
|
||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||
// Page picker: PDF pages rendered small, after the pages on screen (Issue #40)
|
||||
for pdf in pdf_thumbnails_wanted {
|
||||
let pixels = ink_notes::pdf_doc::THUMBNAIL_RENDER_PIXELS;
|
||||
let key = format!("{}@{pixels}", pdf.key());
|
||||
let result = match assets.path_of(&pdf.asset) {
|
||||
Ok(path) => pdf_renderer.take(&key, path, pdf.page as usize, pixels),
|
||||
Err(err) => Some(Err(err.to_string())),
|
||||
};
|
||||
let texture = match result {
|
||||
None => continue,
|
||||
Some(Ok(levels)) => levels.first().map(|image| {
|
||||
let size = [image.width as usize, image.height as usize];
|
||||
ui::Thumbnail(egui_ctx.load_texture(
|
||||
&key,
|
||||
egui::ColorImage::from_rgba_premultiplied(size, &image.pixels),
|
||||
egui::TextureOptions::LINEAR,
|
||||
))
|
||||
}),
|
||||
Some(Err(err)) => {
|
||||
eprintln!("[PDF] Vorschau {key}: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
// Bounded memory: a few hundred small pages at most
|
||||
if ui_state.pdf_thumbnails.len() >= 300 {
|
||||
ui_state.pdf_thumbnails.clear();
|
||||
}
|
||||
ui_state.pdf_thumbnails.insert(pdf.key(), texture);
|
||||
window.request_redraw();
|
||||
}
|
||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||
if !canvas.is_waiting_for_images() {
|
||||
let next = notebook.pages.get(notebook.current_page_index + 1);
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::t;
|
||||
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
|
||||
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
|
||||
/// Longest edge of a page picker thumbnail (sharp at display scale 2).
|
||||
pub const THUMBNAIL_RENDER_PIXELS: u32 = 256;
|
||||
|
||||
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
|
||||
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
|
||||
|
||||
@@ -227,6 +227,8 @@ pub struct UiSettings {
|
||||
pub stylus_buttons: Vec<StylusButton>,
|
||||
/// Sprache der Oberfläche; ohne Angabe die des Systems
|
||||
pub language: Option<Language>,
|
||||
/// Eraser radius in mm (Issue #38)
|
||||
pub eraser_radius_mm: f32,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -246,6 +248,7 @@ impl Default for UiSettings {
|
||||
active_text_color: 0,
|
||||
stylus_buttons: Vec::new(),
|
||||
language: None,
|
||||
eraser_radius_mm: 3.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +348,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
language: Some(Language::English),
|
||||
eraser_radius_mm: 6.0,
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
@@ -110,6 +110,9 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||
|
||||
/// Eraser radii in mm, small to large; the toolbar button cycles through them (Issue #38).
|
||||
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
|
||||
|
||||
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -247,6 +250,8 @@ pub enum UiAction {
|
||||
GoToPage(usize),
|
||||
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
|
||||
LoadAllPages,
|
||||
/// Render these PDF pages small for the page picker (see `UiState::pdf_thumbnails`)
|
||||
RequestPdfThumbnails(Vec<crate::document::PdfBackground>),
|
||||
SetBackground(PageBackground),
|
||||
ExportPdf,
|
||||
ZoomIn,
|
||||
@@ -345,6 +350,16 @@ pub enum UiAction {
|
||||
ForceExit,
|
||||
}
|
||||
|
||||
/// Texture of a page picker thumbnail (egui's handle has no `Debug`).
|
||||
#[derive(Clone)]
|
||||
pub struct Thumbnail(pub egui::TextureHandle);
|
||||
|
||||
impl std::fmt::Debug for Thumbnail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Thumbnail({:?})", self.0.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand der grafischen Benutzeroberfläche.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UiState {
|
||||
@@ -433,6 +448,11 @@ pub struct UiState {
|
||||
pub is_more_menu_open: bool,
|
||||
/// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste)
|
||||
pub eraser_mode: EraserMode,
|
||||
/// Eraser radius in mm, one of [`ERASER_RADII_MM`] (also for the pen's eraser end)
|
||||
pub eraser_radius_mm: f32,
|
||||
/// Small renders of PDF pages for the page picker by [`crate::document::PdfBackground::key`];
|
||||
/// `None` if the page could not be rendered (Issue #40)
|
||||
pub pdf_thumbnails: std::collections::HashMap<String, Option<Thumbnail>>,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
@@ -537,6 +557,8 @@ impl Default for UiState {
|
||||
language: None,
|
||||
is_more_menu_open: false,
|
||||
eraser_mode: EraserMode::All,
|
||||
eraser_radius_mm: ERASER_RADII_MM[1],
|
||||
pdf_thumbnails: std::collections::HashMap::new(),
|
||||
paper_theme: PaperTheme::White,
|
||||
adapt_ink_colors: true,
|
||||
pattern_color: PatternColor::Light,
|
||||
@@ -573,6 +595,28 @@ impl UiState {
|
||||
/// Abstand der Werkzeugleiste zum Fensterrand.
|
||||
pub const SIDEBAR_MARGIN: f32 = 10.0;
|
||||
|
||||
/// Index of the eraser size in [`ERASER_RADII_MM`] closest to the current radius.
|
||||
pub fn eraser_size_index(&self) -> usize {
|
||||
(0..ERASER_RADII_MM.len())
|
||||
.min_by(|&a, &b| {
|
||||
let da = (ERASER_RADII_MM[a] - self.eraser_radius_mm).abs();
|
||||
let db = (ERASER_RADII_MM[b] - self.eraser_radius_mm).abs();
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Sets the eraser radius to the closest available size (e.g. from the settings file).
|
||||
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
|
||||
self.eraser_radius_mm = mm;
|
||||
self.eraser_radius_mm = ERASER_RADII_MM[self.eraser_size_index()];
|
||||
}
|
||||
|
||||
/// Eraser radius in page units.
|
||||
pub fn eraser_radius(&self) -> f32 {
|
||||
self.eraser_radius_mm * crate::document::UNITS_PER_MM
|
||||
}
|
||||
|
||||
/// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit
|
||||
/// sich nie mehrere überlagern.
|
||||
pub fn close_overlays(&mut self) {
|
||||
@@ -933,6 +977,8 @@ enum SidebarIcon<'a> {
|
||||
Lasso,
|
||||
/// Selbst gezeichnetes Lineal mit Skala
|
||||
Ruler,
|
||||
/// Filled circle with this radius in points (eraser size)
|
||||
Dot(f32),
|
||||
}
|
||||
|
||||
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
|
||||
@@ -1035,6 +1081,9 @@ fn sidebar_button(
|
||||
stroke,
|
||||
));
|
||||
}
|
||||
SidebarIcon::Dot(radius) => {
|
||||
painter.circle_filled(c, radius, color);
|
||||
}
|
||||
SidebarIcon::Ruler => {
|
||||
let dir = egui::vec2(1.0, -1.0).normalized();
|
||||
let perp = egui::vec2(dir.y, -dir.x);
|
||||
@@ -1338,12 +1387,14 @@ const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
||||
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` in der Papierfarbe
|
||||
/// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind
|
||||
/// flacher). Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig
|
||||
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
|
||||
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. `pdf_texture` is the
|
||||
/// rendered PDF page under the ink; without it the PDF page is a plain gray area.
|
||||
pub fn page_thumbnail_shapes(
|
||||
page: &Page,
|
||||
rect: egui::Rect,
|
||||
paper: PaperTheme,
|
||||
adapt_ink: bool,
|
||||
pdf_texture: Option<egui::TextureId>,
|
||||
) -> Vec<egui::Shape> {
|
||||
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
|
||||
let size = egui::vec2(page.size.x, page.size.y) * scale;
|
||||
@@ -1359,18 +1410,27 @@ pub fn page_thumbnail_shapes(
|
||||
let adapt = adapt_ink && paper.is_dark();
|
||||
let to_screen = |p: glam::Vec2| sheet.min + egui::vec2(p.x * scale, p.y * scale);
|
||||
|
||||
// Importierte PDF-Seite als helle Fläche mit Rand
|
||||
// Importierte PDF-Seite: gerendert oder als helle Fläche mit Rand
|
||||
if let Some(pdf) = &page.pdf_background {
|
||||
let points: Vec<egui::Pos2> = pdf
|
||||
.corners(page.size)
|
||||
.iter()
|
||||
.map(|&c| to_screen(c))
|
||||
.collect();
|
||||
shapes.push(egui::Shape::convex_polygon(
|
||||
points,
|
||||
egui::Color32::from_rgb(236, 238, 243),
|
||||
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
|
||||
));
|
||||
if let Some(texture) = pdf_texture {
|
||||
shapes.push(egui::Shape::image(
|
||||
texture,
|
||||
egui::Rect::from_two_pos(points[0], points[2]),
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
));
|
||||
} else {
|
||||
shapes.push(egui::Shape::convex_polygon(
|
||||
points,
|
||||
egui::Color32::from_rgb(236, 238, 243),
|
||||
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Bilder als Flächen unter der Tinte
|
||||
@@ -1564,13 +1624,15 @@ fn linear_color32(color: [f32; 4]) -> egui::Color32 {
|
||||
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
|
||||
}
|
||||
|
||||
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
|
||||
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter. A visible PDF page
|
||||
/// that has not been rendered small yet is added to `wanted_pdfs`.
|
||||
fn page_thumbnail(
|
||||
ui: &mut egui::Ui,
|
||||
state: &UiState,
|
||||
page: &Page,
|
||||
number: usize,
|
||||
is_current: bool,
|
||||
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
|
||||
) -> egui::Response {
|
||||
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
@@ -1578,12 +1640,23 @@ fn page_thumbnail(
|
||||
rect.min,
|
||||
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
||||
);
|
||||
let pdf_texture = match &page.pdf_background {
|
||||
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
|
||||
Some(texture) => texture.as_ref().map(|t| t.0.id()),
|
||||
None => {
|
||||
wanted_pdfs.push(pdf.clone());
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let painter = ui.painter();
|
||||
painter.extend(page_thumbnail_shapes(
|
||||
page,
|
||||
thumb,
|
||||
state.paper_theme,
|
||||
state.adapt_ink_colors,
|
||||
pdf_texture,
|
||||
));
|
||||
if !page.is_loaded {
|
||||
painter.text(
|
||||
@@ -3144,7 +3217,7 @@ pub fn render_ui(
|
||||
(
|
||||
ActiveTool::Eraser,
|
||||
SidebarIcon::Eraser,
|
||||
t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"),
|
||||
t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe"),
|
||||
),
|
||||
(
|
||||
ActiveTool::Select,
|
||||
@@ -3319,7 +3392,8 @@ pub fn render_ui(
|
||||
}
|
||||
let spacing = ui.spacing().item_spacing.y;
|
||||
let mut used = shown as f32 * (swatch + spacing);
|
||||
// Radierer: statt Farben die Radier-Modi im selben Platz
|
||||
// Eraser: its modes and size take the place of the colors (swatch-sized, so that
|
||||
// all four fit)
|
||||
if state.active_tool == ActiveTool::Eraser {
|
||||
for (mode, icon, indicator, tooltip) in [
|
||||
(
|
||||
@@ -3343,7 +3417,7 @@ pub fn render_ui(
|
||||
] {
|
||||
if sidebar_button(
|
||||
ui,
|
||||
btn,
|
||||
swatch,
|
||||
icon,
|
||||
state.eraser_mode == mode,
|
||||
true,
|
||||
@@ -3354,8 +3428,35 @@ pub fn render_ui(
|
||||
{
|
||||
state.eraser_mode = mode;
|
||||
}
|
||||
used += btn + spacing;
|
||||
used += swatch + spacing;
|
||||
}
|
||||
// Eraser size: tapping cycles small → large (Issue #38)
|
||||
let index = state.eraser_size_index();
|
||||
let diameter = format!("{}", ERASER_RADII_MM[index] * 2.0);
|
||||
let tooltip = t!(
|
||||
"Radiergröße {size} mm – tippen für die nächste Größe",
|
||||
size = diameter
|
||||
);
|
||||
let dot = [2.5_f32, 4.0, 6.0, 8.5][index].min(swatch * 0.45);
|
||||
if sidebar_button(
|
||||
ui,
|
||||
swatch,
|
||||
SidebarIcon::Dot(dot),
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
&tooltip,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let next = (index + 1) % ERASER_RADII_MM.len();
|
||||
state.eraser_radius_mm = ERASER_RADII_MM[next];
|
||||
state.set_export_message(t!(
|
||||
"Radiergröße {size} mm",
|
||||
size = format!("{}", ERASER_RADII_MM[next] * 2.0)
|
||||
));
|
||||
}
|
||||
used += swatch + spacing;
|
||||
}
|
||||
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
|
||||
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
|
||||
@@ -4268,6 +4369,7 @@ pub fn render_ui(
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(420.0)
|
||||
.show(ui, |ui| {
|
||||
let mut wanted_pdfs = Vec::new();
|
||||
egui::Grid::new("page_picker_grid")
|
||||
.spacing([8.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
@@ -4281,6 +4383,7 @@ pub fn render_ui(
|
||||
page,
|
||||
idx + 1,
|
||||
is_current,
|
||||
&mut wanted_pdfs,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
@@ -4292,6 +4395,10 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
});
|
||||
if !wanted_pdfs.is_empty() {
|
||||
actions
|
||||
.push(UiAction::RequestPdfThumbnails(wanted_pdfs));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
@@ -5710,6 +5817,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eraser_size_snaps_to_the_available_sizes() {
|
||||
let mut state = UiState::new();
|
||||
assert_eq!(state.eraser_radius(), 30.0);
|
||||
// Values from the settings file snap to the closest size
|
||||
state.set_eraser_radius_mm(7.0);
|
||||
assert_eq!(state.eraser_radius_mm, 6.0);
|
||||
assert_eq!(state.eraser_size_index(), 2);
|
||||
state.set_eraser_radius_mm(100.0);
|
||||
assert_eq!(state.eraser_radius_mm, 12.0);
|
||||
state.set_eraser_radius_mm(f32::NAN);
|
||||
assert!(ERASER_RADII_MM.contains(&state.eraser_radius_mm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_view_mode_keeps_only_harmless_keys() {
|
||||
use winit::keyboard::{Key, NamedKey, SmolStr};
|
||||
@@ -6314,13 +6435,13 @@ mod tests {
|
||||
// Leere Seite: nur das weiße Blatt
|
||||
let empty = Page::new(1, PageBackground::Blank);
|
||||
assert_eq!(
|
||||
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true).len(),
|
||||
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true, None).len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Striche liegen innerhalb der Kachel
|
||||
let page = page_with_strokes(3, 20);
|
||||
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true);
|
||||
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true, None);
|
||||
assert_eq!(shapes.len(), 1 + 3);
|
||||
let pts = shape_points(&shapes);
|
||||
assert!(!pts.is_empty());
|
||||
@@ -6331,7 +6452,13 @@ mod tests {
|
||||
|
||||
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
|
||||
let full = page_with_strokes(100, 1000);
|
||||
let pts = shape_points(&page_thumbnail_shapes(&full, rect, PaperTheme::White, true));
|
||||
let pts = shape_points(&page_thumbnail_shapes(
|
||||
&full,
|
||||
rect,
|
||||
PaperTheme::White,
|
||||
true,
|
||||
None,
|
||||
));
|
||||
assert!(
|
||||
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
|
||||
"{} Punkte statt höchstens {}",
|
||||
@@ -6354,12 +6481,27 @@ mod tests {
|
||||
],
|
||||
),
|
||||
);
|
||||
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
|
||||
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true, None);
|
||||
assert_eq!(shapes.len(), 3);
|
||||
assert!(
|
||||
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
|
||||
);
|
||||
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
|
||||
|
||||
// PDF page: the rendered page fills the PDF area, otherwise a gray area (Issue #40)
|
||||
let mut with_pdf = Page::new(2, PageBackground::Blank);
|
||||
with_pdf.pdf_background = Some(crate::document::PdfBackground {
|
||||
asset: "a.pdf".into(),
|
||||
page: 0,
|
||||
width_pt: 595.0,
|
||||
height_pt: 842.0,
|
||||
});
|
||||
let gray = page_thumbnail_shapes(&with_pdf, rect, PaperTheme::White, true, None);
|
||||
assert!(matches!(gray[1], egui::Shape::Path(ref p) if p.closed));
|
||||
let texture = egui::TextureId::User(7);
|
||||
let shapes = page_thumbnail_shapes(&with_pdf, rect, PaperTheme::White, true, Some(texture));
|
||||
assert!(matches!(shapes[1], egui::Shape::Mesh(ref m) if m.texture_id == texture));
|
||||
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user