feat: Schneller durch lange Notizbücher scrollen (Issue #28), neue Notizbücher durchgehend (Issue #30)
- Nachgleiten: Nach schnellem Wischen mit dem Finger gleitet das Blatt weiter und bremst ab; eine neue Berührung, Klick oder Mausrad hält an - Bildlaufleiste in der durchgehenden Ansicht gegenüber der Werkzeugleiste: Griff ziehen oder auf die Leiste tippen springt durch das Notizbuch, beim Ziehen steht die Seitenzahl daneben - Mausrad scrollt doppelt so weit je Raste - Neue Notizbücher (auch PDF-Importe) sind durchgehend; beim Anlegen stehen nur noch Durchgehend und Endlos zur Wahl, feste Seiten gibt es weiter im ☰-Menü Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
### Seiten und Ansicht
|
||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
||||
- **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
|
||||
- **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, auf der man in alle Richtungen schreibt; 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.
|
||||
|
||||
@@ -843,6 +843,18 @@ impl CanvasState {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Scrollt senkrecht, bis die Fenstermitte auf Höhe `y`
|
||||
/// (Koordinaten der aktuellen Seite) liegt.
|
||||
pub fn scroll_center_to(&mut self, y: f32) {
|
||||
let c = self.camera.screen_to_page_unclamped(
|
||||
self.viewport.page_x + self.viewport.page_width / 2.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
&self.viewport,
|
||||
);
|
||||
self.camera.pan_offset[1] += c.y - y;
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
|
||||
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
|
||||
/// Seite mit [`Self::sync_page`] zu setzen.
|
||||
|
||||
+29
-2
@@ -1422,6 +1422,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Langes Drücken mit einem Finger öffnet das Kontextmenü
|
||||
let mut long_press = ui::LongPress::default();
|
||||
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
||||
// Nachgleiten nach schnellem Wischen mit dem Finger (Issue #28)
|
||||
let mut fling = ink_notes::viewport::Fling::default();
|
||||
|
||||
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||
let mut is_stylus_drawing = false;
|
||||
@@ -1961,6 +1963,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Nachgleiten nach dem Wischen
|
||||
if let Some(delta) = fling.step(Instant::now()) {
|
||||
canvas.pan(delta.x, delta.y);
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||
if let Some(at) = egui_repaint_at {
|
||||
if Instant::now() >= at {
|
||||
@@ -1976,6 +1984,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|| (is_drawing && !stroke_already_snapped)
|
||||
|| is_stylus_drawing
|
||||
|| is_stylus_in_proximity
|
||||
|| fling.is_active()
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
||||
@@ -2080,6 +2089,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pinch_prev_center = None;
|
||||
is_touch_drawing = false;
|
||||
is_touch_panning = false;
|
||||
fling.stop();
|
||||
is_stylus_drawing = false;
|
||||
is_stylus_selecting = false;
|
||||
is_stylus_in_proximity = false;
|
||||
@@ -2536,6 +2546,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
fling.stop();
|
||||
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
||||
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
|
||||
is_panning = true;
|
||||
@@ -2721,6 +2732,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
fling.stop();
|
||||
if is_pointer_in_ui {
|
||||
return;
|
||||
}
|
||||
@@ -2729,7 +2741,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
|
||||
let (mut dx, mut dy) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => {
|
||||
let line = 60.0 * window.scale_factor() as f32;
|
||||
let line = 120.0 * window.scale_factor() as f32;
|
||||
(x * line, y * line)
|
||||
}
|
||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
|
||||
@@ -2802,6 +2814,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
match touch.phase {
|
||||
TouchPhase::Started => {
|
||||
// Jede neue Berührung hält ein gleitendes Blatt an
|
||||
fling.stop();
|
||||
// Event-Isolation für Touch-Eingaben
|
||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
@@ -2957,6 +2971,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dy = ty - touch_pan_last_pos.1;
|
||||
touch_pan_last_pos = (tx, ty);
|
||||
canvas.pan(dx, dy);
|
||||
fling.track(Vec2::new(dx, dy), Instant::now());
|
||||
window.request_redraw();
|
||||
} else if is_touch_drawing {
|
||||
current_pressure = pressure;
|
||||
@@ -3003,6 +3018,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_pinch_gesturing = false;
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
if is_touch_panning && touch.phase == TouchPhase::Ended {
|
||||
fling.release(Instant::now());
|
||||
window.request_redraw();
|
||||
}
|
||||
is_touch_panning = false;
|
||||
|
||||
if is_erasing {
|
||||
@@ -3067,7 +3086,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_fit_box(notebook.fit_box());
|
||||
if ui_state.continuous_scroll {
|
||||
let stack = notebook.page_stack();
|
||||
canvas.clamp_scroll(&stack);
|
||||
if canvas.clamp_scroll(&stack) {
|
||||
// Am Anfang oder Ende des Notizbuchs angekommen
|
||||
fling.stop();
|
||||
}
|
||||
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
|
||||
let scroll = [
|
||||
canvas.camera.zoom,
|
||||
@@ -3846,6 +3868,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::ScrollTo(y) => {
|
||||
fling.stop();
|
||||
canvas.scroll_center_to(y);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetNotebookLayout(layout) => {
|
||||
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
|
||||
let was_endless = notebook.is_endless();
|
||||
|
||||
+6
-6
@@ -330,7 +330,7 @@ impl NotebookManifest {
|
||||
page_ids: vec![1],
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 2,
|
||||
layout: Some(NotebookLayout::Paged),
|
||||
layout: Some(NotebookLayout::Continuous),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1640,14 +1640,14 @@ mod tests {
|
||||
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
|
||||
// Neue Notizbücher: feste Seiten, unabhängig von der früheren globalen Einstellung
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
|
||||
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
|
||||
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
|
||||
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
|
||||
|
||||
manifest.layout = Some(NotebookLayout::Continuous);
|
||||
manifest.layout = Some(NotebookLayout::Paged);
|
||||
save_manifest(&dir, &manifest).unwrap();
|
||||
let (_nb, loaded) = load_notebook(&dir).unwrap();
|
||||
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
|
||||
assert_eq!(loaded.layout, Some(NotebookLayout::Paged));
|
||||
|
||||
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
|
||||
let mut json: serde_json::Value =
|
||||
|
||||
@@ -320,6 +320,9 @@ pub enum UiAction {
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
|
||||
SetNotebookLayout(NotebookLayout),
|
||||
/// Durchgehende Ansicht: Fenstermitte auf diese Höhe (Koordinaten der aktuellen Seite)
|
||||
/// scrollen – Bildlaufleiste, Issue #28
|
||||
ScrollTo(f32),
|
||||
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
|
||||
SetPatternColor(PatternColor),
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
@@ -455,6 +458,8 @@ pub struct UiState {
|
||||
/// Eingeblendetes Lineal
|
||||
pub ruler: Option<Ruler>,
|
||||
pub ruler_drag: Option<RulerDrag>,
|
||||
/// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs
|
||||
pub scrollbar_grab: Option<f32>,
|
||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||
pub page_view: Option<PageView>,
|
||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||
@@ -491,7 +496,7 @@ impl Default for UiState {
|
||||
document_title: t!("Standard-Notizbuch").to_string(),
|
||||
available_notebooks: Vec::new(),
|
||||
new_notebook_title_input: String::new(),
|
||||
new_notebook_layout: NotebookLayout::Paged,
|
||||
new_notebook_layout: NotebookLayout::Continuous,
|
||||
renaming_notebook_id: None,
|
||||
library: crate::library::Library::default(),
|
||||
notebook_folder: String::new(),
|
||||
@@ -543,6 +548,7 @@ impl Default for UiState {
|
||||
queued_actions: Vec::new(),
|
||||
ruler: None,
|
||||
ruler_drag: None,
|
||||
scrollbar_grab: None,
|
||||
page_view: None,
|
||||
screen_size: egui::Vec2::ZERO,
|
||||
page_title_input: String::new(),
|
||||
@@ -1653,8 +1659,14 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
|
||||
/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde.
|
||||
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
|
||||
/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen,
|
||||
/// Issue #30 – umstellen geht im ☰-Menü). `true`, wenn sie geändert wurde.
|
||||
fn layout_picker(
|
||||
ui: &mut egui::Ui,
|
||||
layout: &mut NotebookLayout,
|
||||
can_cross: bool,
|
||||
offer_paged: bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let endless_now = *layout == NotebookLayout::Endless;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -1681,6 +1693,9 @@ fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool
|
||||
),
|
||||
),
|
||||
] {
|
||||
if option == NotebookLayout::Paged && !offer_paged {
|
||||
continue;
|
||||
}
|
||||
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
|
||||
if ui
|
||||
.add_enabled(
|
||||
@@ -2344,6 +2359,147 @@ fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lage der Bildlaufleiste: Leiste (Trefferfläche) und Griff in logischen Punkten. Der Griff
|
||||
/// zeigt, wo die Fenstermitte `center` zwischen Ober- und Unterkante des Notizbuchs liegt;
|
||||
/// `visible` ist die sichtbare Höhe (Seiteneinheiten) und bestimmt seine Länge.
|
||||
fn scrollbar_layout(
|
||||
screen: egui::Rect,
|
||||
side: SidebarSide,
|
||||
(top, bottom): (f32, f32),
|
||||
center: f32,
|
||||
visible: f32,
|
||||
) -> (egui::Rect, egui::Rect) {
|
||||
const WIDTH: f32 = 28.0;
|
||||
const MARGIN: f32 = 12.0;
|
||||
// Gegenüber der Werkzeugleiste
|
||||
let x = match side {
|
||||
SidebarSide::Left => screen.right() - WIDTH,
|
||||
SidebarSide::Right => screen.left(),
|
||||
};
|
||||
let track = egui::Rect::from_min_size(
|
||||
egui::pos2(x, screen.top() + MARGIN),
|
||||
egui::vec2(WIDTH, (screen.height() - 2.0 * MARGIN).max(1.0)),
|
||||
);
|
||||
let total = (bottom - top).max(1.0);
|
||||
let thumb_h = (visible / (total + visible) * track.height())
|
||||
.clamp(48.0_f32.min(track.height()), track.height());
|
||||
let fraction = ((center - top) / total).clamp(0.0, 1.0);
|
||||
let thumb = egui::Rect::from_min_size(
|
||||
egui::pos2(
|
||||
track.left(),
|
||||
track.top() + fraction * (track.height() - thumb_h),
|
||||
),
|
||||
egui::vec2(WIDTH, thumb_h),
|
||||
);
|
||||
(track, thumb)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Bildlaufleiste am Fensterrand gegenüber der Werkzeugleiste. Den
|
||||
/// Griff ziehen (oder auf die Leiste tippen) springt schnell durch lange Notizbücher und
|
||||
/// PDFs (Issue #28); beim Ziehen steht die Seitenzahl daneben.
|
||||
fn page_scrollbar(
|
||||
ctx: &egui::Context,
|
||||
state: &mut UiState,
|
||||
notebook: &Notebook,
|
||||
actions: &mut Vec<UiAction>,
|
||||
) {
|
||||
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
};
|
||||
if notebook.pages.len() < 2 {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
}
|
||||
let stack = notebook.page_stack();
|
||||
let (min, max) = stack.bounds();
|
||||
let screen = ctx.screen_rect();
|
||||
let center = view.to_page(screen.center()).y;
|
||||
let visible = screen.height() / view.scale.max(1e-6);
|
||||
let (track, thumb) =
|
||||
scrollbar_layout(screen, state.sidebar_side, (min.y, max.y), center, visible);
|
||||
|
||||
let dark = state.paper_theme.is_dark();
|
||||
let response = egui::Area::new(egui::Id::new("page_scrollbar"))
|
||||
.order(egui::Order::Middle)
|
||||
.fixed_pos(track.min)
|
||||
.show(ctx, |ui| {
|
||||
let (_, response) = ui.allocate_exact_size(track.size(), egui::Sense::drag());
|
||||
let active = response.hovered() || response.dragged();
|
||||
let bar_w = if active { 10.0 } else { 6.0 };
|
||||
let bar = egui::Rect::from_center_size(
|
||||
egui::pos2(thumb.center().x, thumb.center().y),
|
||||
egui::vec2(bar_w, thumb.height()),
|
||||
);
|
||||
let alpha = if active { 200 } else { 110 };
|
||||
let color = if dark {
|
||||
egui::Color32::from_white_alpha(alpha)
|
||||
} else {
|
||||
egui::Color32::from_black_alpha(alpha)
|
||||
};
|
||||
ui.painter()
|
||||
.rect_filled(bar, egui::Rounding::same(bar_w / 2.0), color);
|
||||
response
|
||||
})
|
||||
.inner;
|
||||
|
||||
if response.drag_started() {
|
||||
let y = response
|
||||
.interact_pointer_pos()
|
||||
.map_or(thumb.center().y, |p| p.y);
|
||||
// Auf den Griff: dort festhalten; daneben: Griff unter den Finger springen lassen
|
||||
state.scrollbar_grab = Some(if thumb.y_range().contains(y) {
|
||||
y - thumb.top()
|
||||
} else {
|
||||
thumb.height() / 2.0
|
||||
});
|
||||
}
|
||||
if !response.dragged() {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
}
|
||||
let (Some(grab), Some(pos)) = (state.scrollbar_grab, response.interact_pointer_pos()) else {
|
||||
return;
|
||||
};
|
||||
let free = (track.height() - thumb.height()).max(1.0);
|
||||
let fraction = ((pos.y - grab - track.top()) / free).clamp(0.0, 1.0);
|
||||
let target = min.y + fraction * (max.y - min.y);
|
||||
if (target - center).abs() > 0.5 {
|
||||
actions.push(UiAction::ScrollTo(target));
|
||||
}
|
||||
if let Some(page) = stack.page_at(target) {
|
||||
let label_x = match state.sidebar_side {
|
||||
SidebarSide::Left => track.left() - 8.0,
|
||||
SidebarSide::Right => track.right() + 8.0,
|
||||
};
|
||||
let anchor = match state.sidebar_side {
|
||||
SidebarSide::Left => egui::Align2::RIGHT_CENTER,
|
||||
SidebarSide::Right => egui::Align2::LEFT_CENTER,
|
||||
};
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||
egui::Order::Foreground,
|
||||
egui::Id::new("page_scrollbar_label"),
|
||||
));
|
||||
let text = format!("{} / {}", page + 1, notebook.pages.len());
|
||||
let galley =
|
||||
painter.layout_no_wrap(text, egui::FontId::proportional(18.0), egui::Color32::WHITE);
|
||||
let rect = anchor
|
||||
.anchor_size(egui::pos2(label_x, pos.y), galley.size())
|
||||
.expand2(egui::vec2(10.0, 6.0));
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::Rounding::same(8.0),
|
||||
egui::Color32::from_black_alpha(190),
|
||||
);
|
||||
painter.galley(
|
||||
rect.min + egui::vec2(10.0, 6.0),
|
||||
galley,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
||||
@@ -2751,6 +2907,7 @@ pub fn render_ui(
|
||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||
state.screen_size = ctx.screen_rect().size();
|
||||
page_separators(ctx, state, notebook);
|
||||
page_scrollbar(ctx, state, notebook, &mut actions);
|
||||
space_preview(ctx, state, notebook);
|
||||
ruler_overlay(ctx, state);
|
||||
text_edit_overlay(ctx, state);
|
||||
@@ -3823,7 +3980,7 @@ pub fn render_ui(
|
||||
} else {
|
||||
NotebookLayout::Paged
|
||||
};
|
||||
if layout_picker(ui, &mut layout, state.endless_switch_possible) {
|
||||
if layout_picker(ui, &mut layout, state.endless_switch_possible, true) {
|
||||
actions.push(UiAction::SetNotebookLayout(layout));
|
||||
}
|
||||
|
||||
@@ -4173,7 +4330,7 @@ pub fn render_ui(
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(t!("Seiten:"));
|
||||
layout_picker(ui, &mut state.new_notebook_layout, true);
|
||||
layout_picker(ui, &mut state.new_notebook_layout, true, false);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
@@ -5027,6 +5184,86 @@ mod tests {
|
||||
ctx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_scrollbar_jumps_through_the_notebook() {
|
||||
// Layout: Griff oben am Anfang, unten am Ende, gegenüber der Werkzeugleiste
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 850.0));
|
||||
let (track, top) =
|
||||
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 0.0, 1000.0);
|
||||
assert_eq!(track.right(), screen.right());
|
||||
assert_eq!(top.top(), track.top());
|
||||
assert!(top.height() >= 48.0);
|
||||
let (_, end) =
|
||||
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 80_000.0, 1000.0);
|
||||
assert_eq!(end.bottom(), track.bottom());
|
||||
let (left, _) = scrollbar_layout(screen, SidebarSide::Right, (0.0, 1.0), 0.0, 1.0);
|
||||
assert_eq!(left.left(), screen.left());
|
||||
|
||||
// Notizbuch mit 80 Seiten, durchgehend, Ansicht auf Seite 1
|
||||
let mut notebook = Notebook::new();
|
||||
for _ in 1..80 {
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
}
|
||||
let mut state = UiState::new();
|
||||
state.continuous_scroll = true;
|
||||
state.page_view = Some(PageView {
|
||||
origin: egui::pos2(300.0, 0.0),
|
||||
scale: 0.25,
|
||||
});
|
||||
let ctx = render_frames(&mut state, ¬ebook, 2);
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let x = screen.right() - 14.0;
|
||||
let mut actions = Vec::new();
|
||||
// Griff greifen und bis ans untere Ende ziehen
|
||||
for (pos, pressed) in [
|
||||
(egui::pos2(x, 30.0), Some(true)),
|
||||
(egui::pos2(x, 400.0), None),
|
||||
(egui::pos2(x, 845.0), None),
|
||||
(egui::pos2(x, 845.0), Some(false)),
|
||||
] {
|
||||
let mut events = vec![egui::Event::PointerMoved(pos)];
|
||||
if let Some(pressed) = pressed {
|
||||
events.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
}
|
||||
ctx.begin_frame(egui::RawInput {
|
||||
screen_rect: Some(screen),
|
||||
events,
|
||||
..Default::default()
|
||||
});
|
||||
actions.extend(render_ui(
|
||||
&ctx,
|
||||
&mut state,
|
||||
¬ebook,
|
||||
AutosaveStatus::Saved,
|
||||
&updater,
|
||||
));
|
||||
let _ = ctx.end_frame();
|
||||
}
|
||||
let targets: Vec<f32> = actions
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
UiAction::ScrollTo(y) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (_, max) = notebook.page_stack().bounds();
|
||||
assert!(targets.len() >= 2, "{actions:?}");
|
||||
assert!(targets.windows(2).all(|w| w[1] >= w[0]));
|
||||
assert!((targets.last().unwrap() - max.y).abs() < 1.0, "{targets:?}");
|
||||
assert!(state.scrollbar_grab.is_none());
|
||||
|
||||
// Feste Seiten: keine Leiste, Tippen an den Rand scrollt nicht
|
||||
state.continuous_scroll = false;
|
||||
let ctx = render_frames(&mut state, ¬ebook, 2);
|
||||
let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(x, 400.0));
|
||||
assert!(!actions.iter().any(|a| matches!(a, UiAction::ScrollTo(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_edit_overlay_takes_typing_and_escape() {
|
||||
let notebook = Notebook::new();
|
||||
|
||||
+118
@@ -305,6 +305,84 @@ 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)]
|
||||
pub struct Fling {
|
||||
velocity: Vec2,
|
||||
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
|
||||
last: Option<std::time::Instant>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
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;
|
||||
/// 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);
|
||||
|
||||
/// 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;
|
||||
}
|
||||
// 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) {
|
||||
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.last = Some(now);
|
||||
if !self.active {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||
pub fn stop(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet.
|
||||
pub fn step(&mut self, now: std::time::Instant) -> Option<Vec2> {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
let dt = self
|
||||
.last
|
||||
.map_or(0.0, |last| now.duration_since(last).as_secs_f32())
|
||||
.min(0.1);
|
||||
self.last = Some(now);
|
||||
// Exakt integriert, damit die Strecke nicht von der Bildrate abhängt
|
||||
let decay = (-Self::FRICTION * dt).exp();
|
||||
let delta = self.velocity * (1.0 - decay) / Self::FRICTION;
|
||||
self.velocity *= decay;
|
||||
if self.velocity.length() < Self::MIN_SPEED {
|
||||
self.stop();
|
||||
}
|
||||
Some(delta)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -316,6 +394,46 @@ mod tests {
|
||||
PageStack::new(vec![A4; count], current)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fling_glides_and_slows_down() {
|
||||
use std::time::{Duration, Instant};
|
||||
let t0 = Instant::now();
|
||||
let ms = |n: u64| t0 + Duration::from_millis(n);
|
||||
let mut fling = Fling::default();
|
||||
// Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s
|
||||
for i in 0..=10 {
|
||||
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
fling.release(ms(105));
|
||||
assert!(fling.is_active());
|
||||
let first = fling.step(ms(121)).unwrap();
|
||||
assert!(first.y < -20.0, "{first:?}");
|
||||
let mut total = first;
|
||||
let mut t = 121;
|
||||
while let Some(d) = fling.step(ms(t)) {
|
||||
total += d;
|
||||
t += 16;
|
||||
assert!(t < 10_000, "gleitet endlos");
|
||||
}
|
||||
// Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss
|
||||
assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}");
|
||||
assert!(!fling.is_active());
|
||||
|
||||
// Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten
|
||||
let mut slow = Fling::default();
|
||||
for i in 0..=10 {
|
||||
slow.track(Vec2::new(0.0, -2.0), ms(i * 10));
|
||||
}
|
||||
slow.release(ms(105));
|
||||
assert!(!slow.is_active());
|
||||
let mut rested = Fling::default();
|
||||
for i in 0..=10 {
|
||||
rested.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
rested.release(ms(400));
|
||||
assert!(!rested.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_continuous_layout() {
|
||||
let stack = a4_stack(5, 1);
|
||||
|
||||
Reference in New Issue
Block a user