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
+3
-3
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker, in vier Größen (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
|
||||
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
||||
@@ -55,7 +55,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, die oben links beginnt und nach rechts und unten beliebig weitergeht; der PDF-Export enthält nur den beschriebenen Bereich.
|
||||
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
|
||||
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder (samt importierter PDF-Seite) oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
|
||||
|
||||
### Notizbücher und Speichern
|
||||
@@ -72,7 +72,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
|
||||
### Eingabegeräte
|
||||
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
|
||||
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
|
||||
- Solange der Stift aufliegt oder in der Nähe ist, und kurz danach, werden Berührungen mit der Hand ignoriert – auch auf der Titelleiste. Liegt die Hand schon auf, wenn der Stift kommt, wird sie bis zum Abheben ignoriert.
|
||||
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
|
||||
|
||||
### Updates
|
||||
|
||||
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
|
||||
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
|
||||
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
|
||||
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only. The back end of the pen erases too.
|
||||
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only, in four sizes (remembered). The back end of the pen erases too.
|
||||
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
|
||||
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
|
||||
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
|
||||
@@ -55,7 +55,7 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
- **Continuous scrolling, endless canvas or fixed pages, per notebook:** chosen when creating a notebook (default: continuous; or endless) and switchable in the ☰ menu – where fixed pages are available too (page by page with arrows in the toolbar). In continuous mode the pages hang seamlessly below each other with a dark line marking the boundary. A quick swipe keeps gliding, and the scrollbar on the edge opposite the toolbar jumps through long PDFs with the page number shown. Writing in the bottom fifth of the last page adds a new page automatically. Endless is a single borderless canvas that grows to the right and down as far as you like, starting at the top left; the PDF export only contains the area you used.
|
||||
- **Insert and delete pages:** ➕ in the toolbar inserts an empty page after the current one. Delete it in the ☰ menu (with confirmation); the most recently deleted page can be restored there while the app is running.
|
||||
- **Zoom** from 25 % to 1000 % (`Ctrl + mouse wheel`, two fingers, `Ctrl + +/-/0`), pan with the middle mouse button, `Space + drag` or one finger.
|
||||
- **Pages** can be appended or inserted right after the current page, and given **page titles**; the page overview shows thumbnails or a table of contents. Page titles become bookmarks in the PDF.
|
||||
- **Pages** can be appended or inserted right after the current page, and given **page titles**; the page overview shows thumbnails (including the imported PDF page) or a table of contents. Page titles become bookmarks in the PDF.
|
||||
- **Toolbar** can be docked left or right (for left- and right-handed use).
|
||||
|
||||
### Notebooks and saving
|
||||
@@ -72,7 +72,7 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
|
||||
### Input devices
|
||||
- Pens via the Wayland or X11 tablet protocol (pressure, eraser end, side button).
|
||||
- While the pen touches the screen or hovers nearby, touches by the hand are ignored.
|
||||
- While the pen touches the screen or hovers nearby, and shortly after, touches by the hand are ignored – also on the title bar. A hand already resting on the screen when the pen comes near is ignored until it is lifted.
|
||||
- Fingers: scroll and zoom by default; drawing with a finger can be enabled in the ☰ menu.
|
||||
|
||||
### Updates
|
||||
|
||||
+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]
|
||||
|
||||
Vendored
+3
@@ -11,6 +11,9 @@ docs of the original crate were removed.
|
||||
are now passed to the frame like a left click: close, minimize and maximize work with a
|
||||
finger, and dragging the title bar moves the window (Issue #36). All changes are marked with
|
||||
`ink-notes patch`.
|
||||
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
||||
the title bar while the pen is near the screen, so a resting palm does not move the window
|
||||
(Issue #39).
|
||||
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
||||
capped lints like a crates.io dependency.
|
||||
|
||||
|
||||
Vendored
+20
@@ -8,6 +8,26 @@ use crate::platform_impl::{ApplicationName, Backend};
|
||||
|
||||
pub use crate::window::Theme;
|
||||
|
||||
/// ink-notes patch: touches on the client-side decorations that start before this instant
|
||||
/// are ignored (palm rejection while a pen is near the screen); `None` accepts them.
|
||||
static IGNORE_DECORATION_TOUCHES_UNTIL: std::sync::Mutex<Option<std::time::Instant>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// ink-notes patch: ignore touches on the client-side decorations until `until`.
|
||||
pub fn ignore_decoration_touches_until(until: Option<std::time::Instant>) {
|
||||
*IGNORE_DECORATION_TOUCHES_UNTIL
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = until;
|
||||
}
|
||||
|
||||
/// ink-notes patch: whether a touch on the decorations is ignored right now.
|
||||
pub(crate) fn decoration_touches_ignored() -> bool {
|
||||
IGNORE_DECORATION_TOUCHES_UNTIL
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some_and(|until| std::time::Instant::now() < until)
|
||||
}
|
||||
|
||||
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
||||
pub trait EventLoopWindowTargetExtWayland {
|
||||
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
||||
|
||||
@@ -35,6 +35,10 @@ impl TouchHandler for WinitState {
|
||||
.data::<SurfaceData>()
|
||||
.and_then(|data| data.parent_surface().cloned());
|
||||
if let Some(parent_surface) = parent_surface.filter(|parent| *parent != surface) {
|
||||
// A palm resting on the title bar while writing must not move or close the window
|
||||
if crate::platform::wayland::decoration_touches_ignored() {
|
||||
return;
|
||||
}
|
||||
let window_id = wayland::make_wid(&parent_surface);
|
||||
let mut window = match self.windows.get_mut().get_mut(&window_id) {
|
||||
Some(window) => window.lock().unwrap(),
|
||||
|
||||
Reference in new issue
Block a user