From fb7257d709e38fbfb8cefd5531e444b3137299a9 Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 9 Sep 2026 22:27:30 +0200 Subject: [PATCH] Scroll on cards --- README.md | 1 + src/view.rs | 127 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 52076e6..6c758a1 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ reshuffle. | Drag a group | Moves the group and everything inside it | | Drag on empty canvas | Rubber-band selection (hold Shift to add) | | Middle- or right-drag | Pan | +| Wheel over a node | Scroll contents that are taller than the node | | Ctrl+wheel | Zoom around the pointer | **Markdown in text nodes**: headings, bold/italic/strikethrough, inline and diff --git a/src/view.rs b/src/view.rs index 80d985c..ed758e2 100644 --- a/src/view.rs +++ b/src/view.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; use eframe::egui::{ self, Align, Color32, CornerRadius, CursorIcon, LayerId, Layout, PointerButton, Pos2, Rect, - RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2, epaint, pos2, vec2, + RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2, + containers::scroll_area::ScrollSource, + epaint, pos2, vec2, }; use eframe::emath::TSTransform; @@ -240,6 +242,8 @@ pub struct CanvasView { pub show_grid: bool, pub snap_to_grid: bool, pub previews: PreviewCache, + /// How far each node's contents are scrolled, and how far they can go. + body_scroll: HashMap, /// Screen rectangle the canvas was drawn in last frame, `None` until the /// canvas has been laid out once. viewport: Option, @@ -247,6 +251,15 @@ pub struct CanvasView { pending_fit: Option, } +/// The scroll position of one node's contents. +#[derive(Debug, Clone, Copy, Default)] +struct BodyScroll { + /// Canvas units the contents are scrolled down by. + offset: f32, + /// The largest useful `offset`: zero when everything already fits. + max: f32, +} + /// A framing request that has to wait for the first frame: until the canvas /// has been laid out, its size and position on screen are still a guess. #[derive(Debug, Clone, Copy, PartialEq)] @@ -277,6 +290,7 @@ impl CanvasView { show_grid: true, snap_to_grid: false, previews: PreviewCache::default(), + body_scroll: HashMap::new(), viewport: None, pending_fit: None, } @@ -546,6 +560,12 @@ impl CanvasView { ) -> ViewOutput { let mut output = ViewOutput::default(); self.selection.prune(&doc.canvas); + // Deleted nodes leave a scroll position behind. The map only ever holds + // nodes that have been on screen, so it outgrows the canvas only once + // one of them is gone. + if self.body_scroll.len() > doc.canvas.nodes.len() { + self.body_scroll.retain(|id, _| doc.canvas.node(id).is_some()); + } if self .editing .as_ref() @@ -623,11 +643,16 @@ impl CanvasView { handle_used, &mut output, ); + // Last: which node the wheel belongs to is only known once they have + // all been drawn. + self.scroll_hovered_body(&scene); output } // -- input ------------------------------------------------------------ + /// Panning is a drag; the wheel is left to the nodes (see + /// [`Self::scroll_hovered_body`]), and only zooms with the modifier held. fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) { if background.dragged_by(PointerButton::Middle) || background.dragged_by(PointerButton::Secondary) @@ -642,15 +667,43 @@ impl CanvasView { let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else { return; }; - let (zoom_delta, scroll) = ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)); + let zoom_delta = ui.input(|i| i.zoom_delta()); if zoom_delta != 1.0 { let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM) / self.to_global.scaling; let anchor = pointer.to_vec2(); self.to_global.translation = anchor + (self.to_global.translation - anchor) * factor; self.to_global.scaling *= factor; - } else if scroll != Vec2::ZERO { - self.to_global.translation += scroll; + } + } + + /// Scrolls the contents of the node under the pointer by the wheel. + /// + /// This runs *after* the nodes have been drawn, because that is when it is + /// known which node the pointer is over and how far its contents reach. The + /// wheel does nothing anywhere else: the canvas is panned by dragging, and + /// egui hands the wheel to zoom instead as soon as the modifier is held, so + /// there is nothing here to share it with. + fn scroll_hovered_body(&mut self, ui: &egui::Ui) { + let delta = ui.input(|i| i.smooth_scroll_delta.y); + if delta == 0.0 { + return; + } + let scale = self.to_global.scaling; + let Self { + hovered_node, + body_scroll, + .. + } = self; + let Some(scroll) = hovered_node.as_ref().and_then(|id| body_scroll.get_mut(id)) else { + return; + }; + // The offset is in canvas units, so the wheel has to cross the zoom to + // move the text by as much as it would anywhere else on screen. + let moved = (scroll.offset - delta / scale).clamp(0.0, scroll.max); + if moved != scroll.offset { + scroll.offset = moved; + ui.ctx().request_repaint(); } } @@ -781,6 +834,7 @@ impl CanvasView { } else { let mut body = scene.new_child( UiBuilder::new() + .id_salt(("body", &id)) .max_rect(inner) .layout(Layout::top_down(Align::Min)), ); @@ -791,9 +845,14 @@ impl CanvasView { cache: &mut self.previews, scale, }; - if let Some(target) = - nodes::render_body(&mut body, &doc.canvas.nodes[index], &mut ctx) - { + // Contents taller than the node scroll instead of being + // cut off. + let scroll = self.body_scroll.get(&id).copied().unwrap_or_default(); + let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| { + nodes::render_body(ui, &doc.canvas.nodes[index], &mut ctx) + }); + self.body_scroll.insert(id.clone(), measure(&shown)); + if let Some(target) = shown.inner { output.open_target = Some(target); } } @@ -943,16 +1002,24 @@ impl CanvasView { return; }; let mut buffer = text.clone(); - let mut body = scene.new_child(UiBuilder::new().max_rect(inner)); + let id = doc.canvas.nodes[index].id.clone(); + let mut body = scene.new_child(UiBuilder::new().id_salt(("editor", &id)).max_rect(inner)); body.set_clip_rect(inner.intersect(scene.clip_rect())); - let response = body.add( - TextEdit::multiline(&mut buffer) - .frame(egui::Frame::NONE) - .desired_width(inner.width()) - .desired_rows(1) - .hint_text(RichText::new("Markdown…").color(palette.text_muted)) - .font(egui::FontId::proportional(14.0)), - ); + // Text longer than the node scrolls, the same as it does when read, and + // from where reading left off. + let scroll = self.body_scroll.get(&id).copied().unwrap_or_default(); + let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| { + ui.add( + TextEdit::multiline(&mut buffer) + .frame(egui::Frame::NONE) + .desired_width(ui.available_width()) + .desired_rows(1) + .hint_text(RichText::new("Markdown…").color(palette.text_muted)) + .font(egui::FontId::proportional(14.0)), + ) + }); + self.body_scroll.insert(id.clone(), measure(&shown)); + let response = shown.inner; if self.focus_editor { response.request_focus(); self.focus_editor = false; @@ -1507,6 +1574,34 @@ pub fn curve_of(canvas: &Canvas, edge: &Edge) -> Option<[Pos2; 4]> { )) } +/// The scroll area a node's contents live in, so a card that holds more than +/// it can show scrolls rather than swallowing the rest, positioned at `offset`. +/// +/// Only the bar scrolls it by itself. The wheel is routed by +/// [`CanvasView::scroll_hovered_body`] instead: a scroll area cannot see the +/// wheel from in here, because it asks `Context::rect_contains_pointer`, which +/// only answers for a layer egui knows as an area — and the canvas layer is a +/// bare sublayer. +/// Dragging is left out too, so that a drag inside a node moves the node and a +/// touch drag on its text does not scroll instead. +/// Where a scroll area ended up: the offset it settled on (the bar may have +/// been dragged, or the text cursor may have pulled it along) and how far it +/// could still go. +fn measure(shown: &egui::scroll_area::ScrollAreaOutput) -> BodyScroll { + let max = (shown.content_size.y - shown.inner_rect.height()).max(0.0); + BodyScroll { + offset: shown.state.offset.y.clamp(0.0, max), + max, + } +} + +fn body_scroll_area(offset: f32) -> egui::ScrollArea { + egui::ScrollArea::vertical() + .scroll_source(ScrollSource::SCROLL_BAR) + .vertical_scroll_offset(offset) + .auto_shrink([false, false]) +} + /// The side of `rect` closest to `point`. pub fn nearest_side(rect: Rect, point: Pos2) -> Side { let candidates = [