Scroll on cards
This commit is contained in:
@@ -36,6 +36,7 @@ reshuffle.
|
|||||||
| Drag a group | Moves the group and everything inside it |
|
| Drag a group | Moves the group and everything inside it |
|
||||||
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
||||||
| Middle- or right-drag | Pan |
|
| 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 |
|
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
|
||||||
|
|
||||||
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
|
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
|
||||||
|
|||||||
+111
-16
@@ -5,7 +5,9 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use eframe::egui::{
|
use eframe::egui::{
|
||||||
self, Align, Color32, CornerRadius, CursorIcon, LayerId, Layout, PointerButton, Pos2, Rect,
|
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;
|
use eframe::emath::TSTransform;
|
||||||
|
|
||||||
@@ -240,6 +242,8 @@ pub struct CanvasView {
|
|||||||
pub show_grid: bool,
|
pub show_grid: bool,
|
||||||
pub snap_to_grid: bool,
|
pub snap_to_grid: bool,
|
||||||
pub previews: PreviewCache,
|
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
|
/// Screen rectangle the canvas was drawn in last frame, `None` until the
|
||||||
/// canvas has been laid out once.
|
/// canvas has been laid out once.
|
||||||
viewport: Option<Rect>,
|
viewport: Option<Rect>,
|
||||||
@@ -247,6 +251,15 @@ pub struct CanvasView {
|
|||||||
pending_fit: Option<PendingFit>,
|
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
|
/// 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.
|
/// has been laid out, its size and position on screen are still a guess.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -277,6 +290,7 @@ impl CanvasView {
|
|||||||
show_grid: true,
|
show_grid: true,
|
||||||
snap_to_grid: false,
|
snap_to_grid: false,
|
||||||
previews: PreviewCache::default(),
|
previews: PreviewCache::default(),
|
||||||
|
body_scroll: HashMap::new(),
|
||||||
viewport: None,
|
viewport: None,
|
||||||
pending_fit: None,
|
pending_fit: None,
|
||||||
}
|
}
|
||||||
@@ -546,6 +560,12 @@ impl CanvasView {
|
|||||||
) -> ViewOutput {
|
) -> ViewOutput {
|
||||||
let mut output = ViewOutput::default();
|
let mut output = ViewOutput::default();
|
||||||
self.selection.prune(&doc.canvas);
|
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
|
if self
|
||||||
.editing
|
.editing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -623,11 +643,16 @@ impl CanvasView {
|
|||||||
handle_used,
|
handle_used,
|
||||||
&mut output,
|
&mut output,
|
||||||
);
|
);
|
||||||
|
// Last: which node the wheel belongs to is only known once they have
|
||||||
|
// all been drawn.
|
||||||
|
self.scroll_hovered_body(&scene);
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- input ------------------------------------------------------------
|
// -- 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) {
|
fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) {
|
||||||
if background.dragged_by(PointerButton::Middle)
|
if background.dragged_by(PointerButton::Middle)
|
||||||
|| background.dragged_by(PointerButton::Secondary)
|
|| background.dragged_by(PointerButton::Secondary)
|
||||||
@@ -642,15 +667,43 @@ impl CanvasView {
|
|||||||
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
|
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
|
||||||
return;
|
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 {
|
if zoom_delta != 1.0 {
|
||||||
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
|
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
|
||||||
/ self.to_global.scaling;
|
/ self.to_global.scaling;
|
||||||
let anchor = pointer.to_vec2();
|
let anchor = pointer.to_vec2();
|
||||||
self.to_global.translation = anchor + (self.to_global.translation - anchor) * factor;
|
self.to_global.translation = anchor + (self.to_global.translation - anchor) * factor;
|
||||||
self.to_global.scaling *= 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 {
|
} else {
|
||||||
let mut body = scene.new_child(
|
let mut body = scene.new_child(
|
||||||
UiBuilder::new()
|
UiBuilder::new()
|
||||||
|
.id_salt(("body", &id))
|
||||||
.max_rect(inner)
|
.max_rect(inner)
|
||||||
.layout(Layout::top_down(Align::Min)),
|
.layout(Layout::top_down(Align::Min)),
|
||||||
);
|
);
|
||||||
@@ -791,9 +845,14 @@ impl CanvasView {
|
|||||||
cache: &mut self.previews,
|
cache: &mut self.previews,
|
||||||
scale,
|
scale,
|
||||||
};
|
};
|
||||||
if let Some(target) =
|
// Contents taller than the node scroll instead of being
|
||||||
nodes::render_body(&mut body, &doc.canvas.nodes[index], &mut ctx)
|
// 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);
|
output.open_target = Some(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -943,16 +1002,24 @@ impl CanvasView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut buffer = text.clone();
|
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()));
|
body.set_clip_rect(inner.intersect(scene.clip_rect()));
|
||||||
let response = body.add(
|
// Text longer than the node scrolls, the same as it does when read, and
|
||||||
TextEdit::multiline(&mut buffer)
|
// from where reading left off.
|
||||||
.frame(egui::Frame::NONE)
|
let scroll = self.body_scroll.get(&id).copied().unwrap_or_default();
|
||||||
.desired_width(inner.width())
|
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| {
|
||||||
.desired_rows(1)
|
ui.add(
|
||||||
.hint_text(RichText::new("Markdown…").color(palette.text_muted))
|
TextEdit::multiline(&mut buffer)
|
||||||
.font(egui::FontId::proportional(14.0)),
|
.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 {
|
if self.focus_editor {
|
||||||
response.request_focus();
|
response.request_focus();
|
||||||
self.focus_editor = false;
|
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`.
|
/// The side of `rect` closest to `point`.
|
||||||
pub fn nearest_side(rect: Rect, point: Pos2) -> Side {
|
pub fn nearest_side(rect: Rect, point: Pos2) -> Side {
|
||||||
let candidates = [
|
let candidates = [
|
||||||
|
|||||||
Reference in New Issue
Block a user