Scroll on cards
This commit is contained in:
+111
-16
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user