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 = [