Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5692e5430 | ||
|
|
6a043802d7 |
No files matched your search
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.30.1" date="2026-10-20">
|
||||
<description>
|
||||
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.30.0" date="2026-10-19">
|
||||
<description>
|
||||
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.30.1" date="2026-10-20">
|
||||
<description>
|
||||
<p>Snapped highlighter shapes keep the highlighter width. Sharp corners such as the tip of a "1" are drawn round instead of cut off, and strokes no longer jump when the pen lifts. Finger swipes glide more reliably.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.30.0" date="2026-10-19">
|
||||
<description>
|
||||
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Fehlerbehebungen Schreiben**: Ein eingerasteter Textmarker behält seine Breite; Spitzen (etwa einer „1“ oder von Hochzahlen) werden rund statt abgeschnitten gezeichnet, und der Strich springt beim Absetzen nicht mehr. Wischen mit dem Finger gleitet zuverlässiger nach
|
||||
- **Schneller scrollen**: Schnelles Wischen mit dem Finger gleitet nach; in der durchgehenden Ansicht springt eine Bildlaufleiste (gegenüber der Werkzeugleiste, mit Seitenzahl beim Ziehen) durch lange Notizbücher und PDFs; das Mausrad scrollt doppelt so weit. Neue Notizbücher sind durchgehend, feste Seiten gibt es weiter im ☰-Menü
|
||||
- **Fehlerbehebung Auswahl**: Verschieben, Drehen und Skalieren großer Auswahlen ist wieder flüssig und lastet den Prozessor nicht mehr aus (seit 0.27 wurde die Auswahl bei jedem Bild mehrfach kopiert). Auf der endlosen Fläche scrollt das Mausrad (Strg+Mausrad zoomt)
|
||||
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
|
||||
|
||||
+5
-1
@@ -1324,9 +1324,13 @@ impl CanvasState {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gleiche Tessellierung wie beim Absetzen (runde Kappen und Ecken), damit der Strich
|
||||
// beim Abheben nicht sichtbar springt (Issue #33)
|
||||
let color = self.current_ink_color;
|
||||
let points = spline::rdp_filter_samples(&self.live_samples, spline::RDP_TOLERANCE_UNITS);
|
||||
let corners = spline::detect_corners(&points, spline::CORNER_ANGLE_THRESHOLD_DEG);
|
||||
let raw_strip =
|
||||
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
|
||||
spline::generate_finalized_triangle_strip(&points, &corners, &self.spline_config);
|
||||
self.live_vertices = raw_strip
|
||||
.into_iter()
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
|
||||
+25
-7
@@ -326,9 +326,12 @@ fn commit_stroke_to_notebook(
|
||||
) {
|
||||
// Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der
|
||||
// vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her
|
||||
if let Some(shape) = &snapped_shape {
|
||||
let stroke_width = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM;
|
||||
if let Some(shape) = snapped_shape
|
||||
.as_ref()
|
||||
.filter(|_| ui_state.active_tool != ActiveTool::Highlighter)
|
||||
{
|
||||
if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) {
|
||||
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||
canvas.cancel_stroke();
|
||||
let stroke = geometry::shape_to_stroke(
|
||||
cross,
|
||||
@@ -347,7 +350,6 @@ fn commit_stroke_to_notebook(
|
||||
|
||||
let new_id = notebook.next_stroke_id();
|
||||
let stroke_opt = if let Some(shape) = snapped_shape {
|
||||
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||
let color = canvas.current_ink_color;
|
||||
canvas.cancel_stroke();
|
||||
Some(geometry::shape_to_stroke(
|
||||
@@ -1931,9 +1933,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
stroke_snapped_shape = Some(shape.clone());
|
||||
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
|
||||
// Achsenkreuz (übernommen wird es beim Absetzen)
|
||||
let shape = axis_cross_with_previous(¬ebook, &shape)
|
||||
.map_or(shape, |(_, cross)| cross);
|
||||
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||
// Textmarker bilden kein Achsenkreuz
|
||||
let shape = if ui_state.active_tool == ActiveTool::Highlighter {
|
||||
shape
|
||||
} else {
|
||||
axis_cross_with_previous(¬ebook, &shape)
|
||||
.map_or(shape, |(_, cross)| cross)
|
||||
};
|
||||
let width_units = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM;
|
||||
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
|
||||
canvas.set_live_shape_mesh(samples, vertices);
|
||||
ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name()));
|
||||
@@ -3019,7 +3026,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
if is_touch_panning && touch.phase == TouchPhase::Ended {
|
||||
fling.release(Instant::now());
|
||||
// Letztes Stück bis zum Abheben noch mitnehmen
|
||||
let (dx, dy) = (tx - touch_pan_last_pos.0, ty - touch_pan_last_pos.1);
|
||||
canvas.pan(dx, dy);
|
||||
fling.track(Vec2::new(dx, dy), Instant::now());
|
||||
let velocity = fling.release(Instant::now());
|
||||
if std::env::var_os("INK_NOTES_DEBUG_FLING").is_some() {
|
||||
eprintln!(
|
||||
"[Nachgleiten] {:.0} px/s beim Abheben, gleitet: {}",
|
||||
velocity.length(),
|
||||
fling.is_active()
|
||||
);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
is_touch_panning = false;
|
||||
|
||||
+64
-3
@@ -601,6 +601,7 @@ pub fn generate_finalized_triangle_strip(
|
||||
|
||||
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
|
||||
const CAP_STEPS: usize = 10;
|
||||
const CORNER_STEPS: usize = 24;
|
||||
let mut vertices = Vec::new();
|
||||
|
||||
// 3. Runde Start-Kappe vor dem ersten Run
|
||||
@@ -622,12 +623,22 @@ pub fn generate_finalized_triangle_strip(
|
||||
// 4. Runs und Ecken-Übergänge verbinden
|
||||
for (run_idx, run) in runs.iter().enumerate() {
|
||||
if run_idx > 0 {
|
||||
// Ecken-Verbindung: Nahtlose C0-Verbindung am Knick
|
||||
// Runde Ecken-Verbindung: Ein voller Kreis um den Knick füllt die Außenseite, sonst
|
||||
// enden beide Abschnitte stumpf und die Spitze (etwa einer „1“) wirkt abgeschnitten.
|
||||
// Alle Dreiecke liegen im Kreis, auch die Übergänge zum vorigen und nächsten Run.
|
||||
let s_corner = &run[0];
|
||||
let p_c = s_corner.position;
|
||||
let pr_c = s_corner.pressure;
|
||||
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
|
||||
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
|
||||
let r = s_corner.radius;
|
||||
let prev_right = runs[run_idx - 1].last().map_or(s_corner.right, |s| s.right);
|
||||
let d = prev_right - p_c;
|
||||
let a0 = d.y.atan2(d.x);
|
||||
for k in 0..=CORNER_STEPS {
|
||||
let a = a0 + (k as f32 / CORNER_STEPS as f32) * std::f32::consts::TAU;
|
||||
let rim = p_c + glam::Vec2::new(a.cos(), a.sin()) * r;
|
||||
vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0));
|
||||
vertices.push(Vertex::new(rim.x, rim.y, pr_c, 1.0));
|
||||
}
|
||||
vertices.push(Vertex::new(s_corner.left.x, s_corner.left.y, pr_c, -1.0));
|
||||
vertices.push(Vertex::new(s_corner.right.x, s_corner.right.y, pr_c, 1.0));
|
||||
}
|
||||
@@ -900,6 +911,56 @@ mod tests {
|
||||
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// Liegt `p` in einem der Dreiecke des Triangle-Strips?
|
||||
fn strip_covers(vertices: &[InkVertex], p: glam::Vec2) -> bool {
|
||||
let pos = |v: &InkVertex| glam::Vec2::new(v.position[0], v.position[1]);
|
||||
let cross = |a: glam::Vec2, b: glam::Vec2, c: glam::Vec2| (b - a).perp_dot(c - a);
|
||||
vertices.windows(3).any(|t| {
|
||||
let (a, b, c) = (pos(&t[0]), pos(&t[1]), pos(&t[2]));
|
||||
let (d1, d2, d3) = (cross(a, b, p), cross(b, c, p), cross(c, a, p));
|
||||
let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(neg && pos)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sharp_corner_tip_is_not_cut_off() {
|
||||
// „1“: schräg hinauf bis (100, 0), dann senkrecht hinunter (Issue #33)
|
||||
let mut raw = Vec::new();
|
||||
for i in 0..=20 {
|
||||
let u = i as f32 / 20.0;
|
||||
raw.push(StylusSample {
|
||||
position: glam::Vec2::new(60.0 + 40.0 * u, 80.0 - 80.0 * u),
|
||||
pressure: 0.5,
|
||||
timestamp: i as f64 * 0.01,
|
||||
});
|
||||
}
|
||||
for i in 1..=30 {
|
||||
raw.push(StylusSample {
|
||||
position: glam::Vec2::new(100.0, i as f32 * 5.0),
|
||||
pressure: 0.5,
|
||||
timestamp: (20 + i) as f64 * 0.01,
|
||||
});
|
||||
}
|
||||
let config = SplineConfig::from_width_units(10.0);
|
||||
let stroke = finalize_stroke_with_config(1, &raw, [0.0; 4], &config);
|
||||
assert_eq!(
|
||||
detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).len(),
|
||||
1
|
||||
);
|
||||
// Die Spitze reicht rund um den Knick so weit wie die Strichbreite
|
||||
let r = calculate_radius(0.5, config.min_radius, config.max_radius) * 0.95;
|
||||
for deg in [-150.0_f32, -120.0, -90.0, -60.0, -30.0, 0.0] {
|
||||
let a = deg.to_radians();
|
||||
let p = glam::Vec2::new(100.0, 0.0) + glam::Vec2::new(a.cos(), a.sin()) * r;
|
||||
assert!(
|
||||
strip_covers(&stroke.vertices, p),
|
||||
"Spitze bei {deg}° abgeschnitten"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_loops_stay_round() {
|
||||
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
|
||||
|
||||
@@ -60,8 +60,9 @@ impl ActiveTool {
|
||||
/// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält.
|
||||
pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool {
|
||||
match self {
|
||||
_ if has_shape => StrokeTool::Shape,
|
||||
// Ein eingerasteter Textmarker bleibt Textmarker (durchscheinend, eigener Radierer)
|
||||
ActiveTool::Highlighter => StrokeTool::Highlighter,
|
||||
_ if has_shape => StrokeTool::Shape,
|
||||
_ => StrokeTool::Pen,
|
||||
}
|
||||
}
|
||||
@@ -5806,6 +5807,11 @@ mod tests {
|
||||
// Eingerastete Form (Formwerkzeug oder Halten am Strichende)
|
||||
assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape);
|
||||
assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape);
|
||||
// Eingerasteter Textmarker (Issue #32)
|
||||
assert_eq!(
|
||||
ActiveTool::Highlighter.stroke_tool(true),
|
||||
StrokeTool::Highlighter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+51
-15
@@ -308,9 +308,11 @@ impl ViewportState {
|
||||
/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der
|
||||
/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab.
|
||||
/// Geschwindigkeiten in Bildschirmpixeln je Sekunde.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Fling {
|
||||
velocity: Vec2,
|
||||
/// Fingerweg (aufsummiert) der letzten [`Self::WINDOW`] mit Zeitpunkt
|
||||
trail: std::collections::VecDeque<(std::time::Instant, Vec2)>,
|
||||
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
|
||||
last: Option<std::time::Instant>,
|
||||
active: bool,
|
||||
@@ -320,37 +322,53 @@ impl Fling {
|
||||
/// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION)
|
||||
const FRICTION: f32 = 2.5;
|
||||
/// Langsamer wischen gleitet nicht nach
|
||||
const MIN_START_SPEED: f32 = 400.0;
|
||||
const MIN_START_SPEED: f32 = 250.0;
|
||||
/// Darunter hört das Gleiten auf
|
||||
const MIN_SPEED: f32 = 40.0;
|
||||
/// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach
|
||||
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(80);
|
||||
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(120);
|
||||
/// Die Geschwindigkeit ergibt sich aus dem Weg in dieser Zeit vor dem Abheben. Über
|
||||
/// mehrere Ereignisse gemessen, weil Touchscreens kurz vor dem Abheben oft nur noch
|
||||
/// winzige Bewegungen melden und Ereignisse gebündelt ankommen, wenn ein Bild länger
|
||||
/// zum Zeichnen braucht.
|
||||
const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Finger hat sich um `delta` verschoben.
|
||||
pub fn track(&mut self, delta: Vec2, now: std::time::Instant) {
|
||||
self.active = false;
|
||||
let dt = self
|
||||
.last
|
||||
.map_or(0.0, |last| now.duration_since(last).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
if dt <= 0.0 {
|
||||
return;
|
||||
let pos = self.trail.back().map_or(Vec2::ZERO, |&(_, p)| p) + delta;
|
||||
self.trail.push_back((now, pos));
|
||||
// Einen Punkt vor dem Fenster behalten, damit es ganz abgedeckt ist
|
||||
while self.trail.len() > 2 && now.duration_since(self.trail[1].0) > Self::WINDOW {
|
||||
self.trail.pop_front();
|
||||
}
|
||||
if delta != Vec2::ZERO {
|
||||
self.last = Some(now);
|
||||
}
|
||||
// Geglättet: einzelne ungleichmäßige Touch-Ereignisse sollen nicht zählen
|
||||
let sample = delta / dt.max(0.004);
|
||||
self.velocity = self.velocity.lerp(sample, 0.4);
|
||||
}
|
||||
|
||||
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde.
|
||||
pub fn release(&mut self, now: std::time::Instant) {
|
||||
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. Gibt die
|
||||
/// gemessene Geschwindigkeit zurück (für die Fehlersuche).
|
||||
pub fn release(&mut self, now: std::time::Instant) -> Vec2 {
|
||||
let resting = self
|
||||
.last
|
||||
.is_none_or(|last| now.duration_since(last) > Self::MAX_REST);
|
||||
self.active = !resting && self.velocity.length() >= Self::MIN_START_SPEED;
|
||||
self.velocity = match (self.trail.front(), self.trail.back()) {
|
||||
(Some(&(t0, p0)), Some(&(_, p1))) => {
|
||||
// Bis zum Abheben gemessen; gebündelte Ereignisse zählen mindestens 10 ms
|
||||
let dt = now.duration_since(t0).as_secs_f32().max(0.01);
|
||||
(p1 - p0) / dt
|
||||
}
|
||||
_ => Vec2::ZERO,
|
||||
};
|
||||
self.trail.clear();
|
||||
let velocity = self.velocity;
|
||||
self.active = !resting && velocity.length() >= Self::MIN_START_SPEED;
|
||||
self.last = Some(now);
|
||||
if !self.active {
|
||||
self.stop();
|
||||
}
|
||||
velocity
|
||||
}
|
||||
|
||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||
@@ -432,6 +450,24 @@ mod tests {
|
||||
}
|
||||
rested.release(ms(400));
|
||||
assert!(!rested.is_active());
|
||||
|
||||
// Touchscreens melden kurz vor dem Abheben oft nur noch winzige Bewegungen
|
||||
let mut lifted = Fling::default();
|
||||
for i in 0..=8 {
|
||||
lifted.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
lifted.track(Vec2::new(0.0, -1.0), ms(90));
|
||||
lifted.track(Vec2::new(0.0, 0.0), ms(100));
|
||||
lifted.release(ms(104));
|
||||
assert!(lifted.is_active());
|
||||
|
||||
// Gebündelt ankommende Ereignisse (ein Bild brauchte 50 ms)
|
||||
let mut batched = Fling::default();
|
||||
for t in [0, 50, 50, 50, 50, 100, 100, 100, 100] {
|
||||
batched.track(Vec2::new(0.0, -20.0), ms(t));
|
||||
}
|
||||
batched.release(ms(100));
|
||||
assert!(batched.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user