Scroll on cards

This commit is contained in:
2026-09-09 22:27:30 +02:00
parent c102b0983d
commit fb7257d709
2 changed files with 112 additions and 16 deletions
+1
View File
@@ -36,6 +36,7 @@ reshuffle.
| Drag a group | Moves the group and everything inside it |
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
| Middle- or right-drag | Pan |
| Wheel over a node | Scroll contents that are taller than the node |
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
+111 -16
View File
@@ -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<String, BodyScroll>,
/// Screen rectangle the canvas was drawn in last frame, `None` until the
/// canvas has been laid out once.
viewport: Option<Rect>,
@@ -247,6 +251,15 @@ pub struct CanvasView {
pending_fit: Option<PendingFit>,
}
/// 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<R>(shown: &egui::scroll_area::ScrollAreaOutput<R>) -> 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 = [