Rework Scrolling and Zooming

This commit is contained in:
2026-09-10 15:28:37 +02:00
parent 79cb3301b5
commit 28559cfd5d
4 changed files with 450 additions and 247 deletions
+6 -6
View File
@@ -29,8 +29,8 @@ reshuffle.
| Gesture | Result | | Gesture | Result |
| --- | --- | | --- | --- |
| Double-click empty canvas | New text note, ready to type in | | Double-click empty canvas | New text note, ready to type in |
| Double-click a note | Edit its Markdown in place | | Double-click a note | Edit its Markdown in an editor window |
| Double-click a file/link node | Open it with the system default application | | Double-click a file/link node | Edit a plain-text file, or open anything else with the system default application |
| Right-click empty canvas | Menu of every node type, added where you clicked | | Right-click empty canvas | Menu of every node type, added where you clicked |
| Right-click a node | Colour, bring to front / send to back, delete | | Right-click a node | Colour, bring to front / send to back, delete |
| Drag a dot on a node's edge | Draw a connection; drop it on empty canvas to create the node too | | Drag a dot on a node's edge | Draw a connection; drop it on empty canvas to create the node too |
@@ -39,8 +39,7 @@ reshuffle.
| 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 |
| Drop files on the window | A file node each; a `.canvas` file opens instead | | Drop files on the window | A file node each; a `.canvas` file opens instead |
| Wheel over a node | Scroll contents that are taller than the node | | Wheel | Zoom around the pointer, anywhere on the canvas |
| <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
fenced code, quotes, bullet, numbered and task lists, rules, images and links. fenced code, quotes, bullet, numbered and task lists, rules, images and links.
@@ -125,8 +124,9 @@ The UI toolkit is [egui](https://github.com/emilk/egui) via `eframe`. An
infinite canvas is essentially one large custom-painted widget, which is what infinite canvas is essentially one large custom-painted widget, which is what
immediate-mode GUI is good at, and it builds to a single binary on both targets immediate-mode GUI is good at, and it builds to a single binary on both targets
without a GTK or Qt runtime. Pan and zoom are a transform on a dedicated egui without a GTK or Qt runtime. Pan and zoom are a transform on a dedicated egui
layer, so real widgets (the in-place Markdown editor, links) keep working while layer, so real widgets (file previews, links) keep working while the canvas is
the canvas is scaled. scaled. Cards crop what they cannot show and editing happens in a window of its
own, so nothing on the canvas is hidden behind a scroll bar.
| Module | Responsibility | | Module | Responsibility |
| --- | --- | | --- | --- |
+1 -1
View File
@@ -1134,7 +1134,7 @@ const SHORTCUTS: &[(&str, &str)] = &[
("Arrow keys", "Nudge (hold Shift for 10 units)"), ("Arrow keys", "Nudge (hold Shift for 10 units)"),
("Double-click", "New note, or edit what is under the cursor"), ("Double-click", "New note, or edit what is under the cursor"),
("Middle / right drag", "Pan the canvas"), ("Middle / right drag", "Pan the canvas"),
("Ctrl+wheel", "Zoom"), ("Wheel", "Zoom around the pointer"),
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"), ("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),
]; ];
+1 -1
View File
@@ -641,7 +641,7 @@ fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut
for tip in [ for tip in [
"Double-click the canvas to add a note", "Double-click the canvas to add a note",
"Drag a dot on a node's edge to connect it", "Drag a dot on a node's edge to connect it",
"Middle- or right-drag to pan, Ctrl+wheel to zoom", "Middle- or right-drag to pan, wheel to zoom",
"Hold Shift to add to the selection", "Hold Shift to add to the selection",
] { ] {
ui.label( ui.label(
+418 -215
View File
@@ -8,7 +8,7 @@ use std::path::PathBuf;
use eframe::egui::{ use eframe::egui::{
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout, self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder,
Vec2, containers::scroll_area::ScrollSource, epaint, pos2, vec2, Vec2, epaint, pos2, vec2,
}; };
use eframe::emath::TSTransform; use eframe::emath::TSTransform;
@@ -40,6 +40,9 @@ const EDGE_HIT_RADIUS: f32 = 8.0;
const HANDLE_SIZE: f32 = 9.0; const HANDLE_SIZE: f32 = 9.0;
const NODE_PADDING: f32 = 10.0; const NODE_PADDING: f32 = 10.0;
const NODE_CORNER: f32 = 6.0; const NODE_CORNER: f32 = 6.0;
/// The size the editor window opens at, and what its placement assumes it to
/// be; from there it is the user's to resize.
const EDITOR_SIZE: Vec2 = vec2(520.0, 380.0);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Selection // Selection
@@ -258,7 +261,7 @@ pub struct CanvasView {
to_global: TSTransform, to_global: TSTransform,
pub selection: Selection, pub selection: Selection,
gesture: Gesture, gesture: Gesture,
/// Node whose text is being edited in place. /// Node the editor is open on.
editing: Option<String>, editing: Option<String>,
/// The text an open file-node editor is working on, while one is open. /// The text an open file-node editor is working on, while one is open.
file_edit: Option<FileEdit>, file_edit: Option<FileEdit>,
@@ -271,8 +274,11 @@ pub struct CanvasView {
pub snap_to_grid: bool, pub snap_to_grid: bool,
/// The text of every file the canvas shows, edits included. /// The text of every file the canvas shows, edits included.
pub files: FileStore, pub files: FileStore,
/// How far each node's contents are scrolled, and how far they can go. /// How tall the contents of the node being edited came out this frame, so
body_scroll: HashMap<String, BodyScroll>, /// that the editor can offer to grow the card to fit them. `None` while
/// nothing is being edited, or while the card is drawn too small to
/// measure.
body_height: Option<f32>,
/// Where the open context menu was summoned, in canvas coordinates. The /// Where the open context menu was summoned, in canvas coordinates. The
/// pointer wanders off while the menu is up, so a node added from it has to /// pointer wanders off while the menu is up, so a node added from it has to
/// land where the menu was opened and not where the click on the entry was. /// land where the menu was opened and not where the click on the entry was.
@@ -301,15 +307,6 @@ struct FileEdit {
buffer: String, buffer: String,
} }
/// 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)]
@@ -340,7 +337,7 @@ impl CanvasView {
connect_target: None, connect_target: None,
snap_to_grid: false, snap_to_grid: false,
files: FileStore::default(), files: FileStore::default(),
body_scroll: HashMap::new(), body_height: None,
menu_at: None, menu_at: None,
viewport: None, viewport: None,
pending_fit: None, pending_fit: None,
@@ -358,14 +355,14 @@ impl CanvasView {
self.file_edit = None; self.file_edit = None;
} }
/// Starts in-place editing of a text node or group label. /// Opens the editor on a text or file node, or on a group's label.
pub fn edit_node(&mut self, id: &str) { pub fn edit_node(&mut self, id: &str) {
self.editing = Some(id.to_owned()); self.editing = Some(id.to_owned());
self.focus_editor = true; self.focus_editor = true;
} }
/// Starts in-place editing of the file behind a file node, reporting /// Opens the editor on the file behind a file node, reporting `false`
/// `false` for a file this editor will not change. /// for a file this editor will not change.
pub fn edit_file_contents(&mut self, doc: &Document, id: &str) -> bool { pub fn edit_file_contents(&mut self, doc: &Document, id: &str) -> bool {
let can = self.can_edit_file(doc, id); let can = self.can_edit_file(doc, id);
if can { if can {
@@ -638,13 +635,8 @@ impl CanvasView {
pub fn show(&mut self, ui: &mut egui::Ui, doc: &mut Document, palette: &Palette) -> ViewOutput { pub fn show(&mut self, ui: &mut egui::Ui, doc: &mut Document, palette: &Palette) -> 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 // Measured again below, by drawing the card the editor is open on.
// nodes that have been on screen, so it outgrows the canvas only once self.body_height = None;
// 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()
@@ -726,16 +718,15 @@ impl CanvasView {
let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas()); let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas());
background.context_menu(|ui| background_menu(ui, at, &mut output)); background.context_menu(|ui| background_menu(ui, at, &mut output));
// Last: which node the wheel belongs to is only known once they have // Last: the editor offers to fit the card to its contents, and how
// all been drawn. // tall those are is only known once the card has been drawn.
self.scroll_hovered_body(&scene); self.show_edit_window(ui.ctx(), doc, palette);
output output
} }
// -- input ------------------------------------------------------------ // -- input ------------------------------------------------------------
/// Panning is a drag; the wheel is left to the nodes (see /// Panning is a drag; the wheel zooms, bare or with a modifier held.
/// [`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)
@@ -750,7 +741,13 @@ 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 = ui.input(|i| i.zoom_delta()); // A bare wheel arrives as scroll and is turned into a zoom by the
// same rule egui uses for the modifier it expects, so that the two
// zoom at one speed. Nothing else on the canvas wants the wheel: it is
// panned by dragging, and node contents are cropped rather than
// scrolled.
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed);
let zoom_delta = ui.input(|i| i.zoom_delta() * (speed * i.smooth_scroll_delta.y).exp());
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;
@@ -760,36 +757,6 @@ impl CanvasView {
} }
} }
/// 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();
}
}
// -- painting --------------------------------------------------------- // -- painting ---------------------------------------------------------
fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) { fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) {
@@ -922,9 +889,6 @@ impl CanvasView {
if scale >= 0.18 && !is_group { if scale >= 0.18 && !is_group {
let inner = rect.shrink(NODE_PADDING); let inner = rect.shrink(NODE_PADDING);
if inner.width() > 4.0 && inner.height() > 4.0 { if inner.width() > 4.0 && inner.height() > 4.0 {
let edited = self.editing.as_deref() == Some(id.as_str())
&& self.show_editor(scene, doc, index, inner, palette);
if !edited {
let mut body = scene.new_child( let mut body = scene.new_child(
UiBuilder::new() UiBuilder::new()
.id_salt(("body", &id)) .id_salt(("body", &id))
@@ -938,16 +902,20 @@ impl CanvasView {
files: &mut self.files, files: &mut self.files,
scale, scale,
}; };
// Contents taller than the node scroll instead of being // Contents taller than the node are cut off by the clip
// cut off. // rectangle: the canvas shows what a card holds, never
let scroll = self.body_scroll.get(&id).copied().unwrap_or_default(); // less. The editor is where the rest of it is read.
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| { if let Some(target) =
nodes::render_body(ui, &doc.canvas.nodes[index], &mut ctx) nodes::render_body(&mut body, &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);
} }
// What the editor would have to grow the card to. Detail
// is dropped from a card drawn small (see
// `nodes::render_body`), and with it any hope of measuring
// one.
if self.editing.as_deref() == Some(id.as_str()) && scale >= 0.4 {
self.body_height = Some(body.min_rect().height());
} }
} }
} }
@@ -1111,95 +1079,95 @@ impl CanvasView {
} }
} }
/// The in-place editor of the node being edited. // -- the editor window ------------------------------------------------
/// The editor for the node being edited, in a window of its own.
/// ///
/// Returns `false` when the node has nothing to edit here — a link, or a /// A card crops what it shows, so editing on the card cropped the writing
/// file that is not plain text — so that the caller draws its contents as /// with it. The window is sized for the text instead: it scrolls, it can
/// usual instead. /// be as large as the screen, and being non-modal it leaves the canvas
fn show_editor( /// live behind it — the card can be dragged and resized while it is open,
&mut self, /// and it shows every keystroke as it lands.
scene: &mut egui::Ui, ///
doc: &mut Document, /// A group's label is still edited on the canvas: one line fits there.
index: usize, fn show_edit_window(&mut self, ctx: &egui::Context, doc: &mut Document, palette: &Palette) {
inner: Rect, let Some(id) = self.editing.clone() else {
palette: &Palette, self.file_edit = None;
) -> bool { return;
match doc.canvas.nodes[index].kind { };
NodeKind::Text { .. } => { let Some(index) = doc.canvas.nodes.iter().position(|node| node.id == id) else {
self.edit_text(scene, doc, index, inner, palette);
true
}
NodeKind::File { .. } => self.edit_file(scene, doc, index, inner, palette),
_ => {
self.stop_editing(); self.stop_editing();
false return;
} };
match doc.canvas.nodes[index].kind {
NodeKind::Text { .. } => self.edit_text(ctx, doc, index, palette),
NodeKind::File { .. } => self.edit_file(ctx, doc, index, palette),
NodeKind::Group { .. } => {}
// Nothing in a link or an unknown node is typed in here.
_ => self.stop_editing(),
} }
} }
/// The in-place editor for a text node, which writes into the canvas. /// The editor for a text node, which writes into the canvas.
fn edit_text( fn edit_text(
&mut self, &mut self,
scene: &mut egui::Ui, ctx: &egui::Context,
doc: &mut Document, doc: &mut Document,
index: usize, index: usize,
inner: Rect,
palette: &Palette, palette: &Palette,
) { ) {
let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else { let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else {
return; return;
}; };
let mut buffer = text.clone(); let mut buffer = text.clone();
let id = doc.canvas.nodes[index].id.clone(); let fits = self.body_height;
let mut body = scene.new_child(UiBuilder::new().id_salt(("editor", &id)).max_rect(inner)); let focus = std::mem::take(&mut self.focus_editor);
body.set_clip_rect(inner.intersect(scene.clip_rect())); let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
// Text longer than the node scrolls, the same as it does when read, and let editor = Editor {
// from where reading left off. font: egui::FontId::proportional(14.0),
let scroll = self.body_scroll.get(&id).copied().unwrap_or_default(); hint: "Markdown\u{2026}",
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| { note: None,
ui.add( focus,
TextEdit::multiline(&mut buffer) content_height: fits,
.frame(egui::Frame::NONE) palette,
.desired_width(ui.available_width()) };
.desired_rows(1) let (open, pass) = editor_window(ctx, "Edit text", at, |ui| {
.hint_text(RichText::new("Markdown\u{2026}").color(palette.text_muted)) editor_contents(ui, &mut buffer, editor)
.font(egui::FontId::proportional(14.0)),
)
}); });
self.body_scroll.insert(id.clone(), measure(&shown));
let response = shown.inner; if pass.changed {
if self.focus_editor {
response.request_focus();
self.focus_editor = false;
}
if response.changed() {
doc.begin_change("Edit text"); doc.begin_change("Edit text");
if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind { if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind {
*text = buffer; *text = buffer;
} }
} }
if response.lost_focus() { if pass.fit
&& let Some(height) = fits
{
self.fit_card(doc, index, height);
}
if pass.close || !open {
self.stop_editing(); self.stop_editing();
doc.end_change(); doc.end_change();
} }
} }
/// The in-place editor for a file node, which writes into the file itself. /// The editor for the file behind a file node, which writes into the file
/// itself.
/// ///
/// The edit stays in [`CanvasView::files`] until the canvas is saved, so /// The edit stays in [`CanvasView::files`] until the canvas is saved, so
/// typing in a node never touches the disk on its own. Returns `false` for /// typing in a node never touches the disk on its own. A file this editor
/// a file this editor will not change; see [`CanvasView::can_edit_file`]. /// will not change closes it again; see [`CanvasView::can_edit_file`].
fn edit_file( fn edit_file(
&mut self, &mut self,
scene: &mut egui::Ui, ctx: &egui::Context,
doc: &mut Document, doc: &mut Document,
index: usize, index: usize,
inner: Rect,
palette: &Palette, palette: &Palette,
) -> bool { ) {
let node = &doc.canvas.nodes[index]; let node = &doc.canvas.nodes[index];
let NodeKind::File { file, subpath } = &node.kind else { let NodeKind::File { file, subpath } = &node.kind else {
return false; return;
}; };
let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone()); let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone());
let path = nodes::resolve_path(doc.base_dir().as_deref(), &file); let path = nodes::resolve_path(doc.base_dir().as_deref(), &file);
@@ -1213,77 +1181,55 @@ impl CanvasView {
{ {
let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else { let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else {
self.stop_editing(); self.stop_editing();
return false; return;
}; };
self.file_edit = Some(FileEdit { self.file_edit = Some(FileEdit {
id: id.clone(), id,
path: path.clone(), path: path.clone(),
section, section,
buffer, buffer,
}); });
} }
let Self { let title = match &subpath {
editing, Some(subpath) => format!("Edit {} {subpath}", crate::model::file_name(&file)),
file_edit, None => format!("Edit {}", crate::model::file_name(&file)),
focus_editor, };
body_scroll, // Said plainly, because this is the one editor whose typing lands
files, // somewhere other than the canvas.
.. let note = format!("Writes to {file}, and is written out when the canvas is saved");
} = self; let fits = self.body_height;
let Some(edit) = file_edit.as_mut() else { let focus = std::mem::take(&mut self.focus_editor);
return false; let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
let editor = Editor {
font: egui::FontId::monospace(13.0),
hint: "",
note: Some(&note),
focus,
content_height: fits,
palette,
}; };
let mut body = scene.new_child( // The buffer and the store are both borrowed out of `self` here, so
UiBuilder::new() // everything that needs the view whole waits until after this block.
.id_salt(("editor", &id)) let (open, pass) = {
.max_rect(inner) let Self {
.layout(Layout::top_down(Align::Min)), file_edit, files, ..
); } = self;
body.set_clip_rect(inner.intersect(scene.clip_rect())); let Some(edit) = file_edit.as_mut() else {
// A header, so that it is clear the typing goes into the file on disk return;
// and not into the canvas. };
body.horizontal(|ui| { let (open, pass) = editor_window(ctx, &title, at, |ui| {
ui.spacing_mut().item_spacing.x = 4.0; editor_contents(ui, &mut edit.buffer, editor)
ui.label(RichText::new("EDIT").size(10.0).color(palette.accent));
ui.label(
RichText::new(crate::model::file_name(&file))
.size(13.0)
.strong()
.color(palette.text),
);
if let Some(subpath) = &subpath {
ui.label(RichText::new(subpath).size(12.0).color(palette.text_muted));
}
}); });
body.add_space(4.0); if pass.changed {
let scroll = body_scroll.get(&id).copied().unwrap_or_default();
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| {
ui.add(
TextEdit::multiline(&mut edit.buffer)
.frame(egui::Frame::NONE)
.desired_width(ui.available_width())
.desired_rows(1)
// The file is shown as it is written, which for anything
// but prose means its columns have to line up.
.font(egui::FontId::monospace(13.0)),
)
});
body_scroll.insert(id.clone(), measure(&shown));
let response = shown.inner;
if *focus_editor {
response.request_focus();
*focus_editor = false;
}
if response.changed() {
match edit.section.clone() { match edit.section.clone() {
// A node showing one section writes that section back into the // A node showing one section writes that section back into
// file around it, leaving the rest of it alone. // the file around it, leaving the rest of it alone.
Some(range) => { Some(range) => {
if let Ok(loaded) = files.entry(&edit.path) { if let Ok(loaded) = files.entry(&edit.path) {
let (text, moved) = files::splice_lines(&loaded.text, range, &edit.buffer); let (text, moved) =
files::splice_lines(&loaded.text, range, &edit.buffer);
edit.section = Some(moved); edit.section = Some(moved);
files.set(&edit.path, text); files.set(&edit.path, text);
} }
@@ -1291,11 +1237,60 @@ impl CanvasView {
None => files.set(&edit.path, edit.buffer.clone()), None => files.set(&edit.path, edit.buffer.clone()),
} }
} }
if response.lost_focus() { (open, pass)
*editing = None; };
*file_edit = None;
if pass.fit
&& let Some(height) = fits
{
self.fit_card(doc, index, height);
}
if pass.close || !open {
self.stop_editing();
}
}
/// Where the editor window goes when it opens on `node`: beside the card,
/// so that neither hides the other, and within the viewport. It stays
/// wherever it is dragged to from there, until it opens on another card.
fn editor_pos(&self, node: &Node) -> Pos2 {
const GAP: f32 = 16.0;
let card = self.to_global * node_rect(node);
let view = self.viewport();
let right = card.right() + GAP;
let left = card.left() - GAP - EDITOR_SIZE.x;
// Beside the card on whichever side has the room, and failing both,
// wherever it fits.
let x = if right + EDITOR_SIZE.x <= view.right() {
right
} else {
left
};
let fit = |v: f32, min: f32, max: f32| v.clamp(min, max.max(min));
pos2(
fit(x, view.left() + GAP, view.right() - EDITOR_SIZE.x - GAP),
fit(
card.top(),
view.top() + GAP,
view.bottom() - EDITOR_SIZE.y - GAP,
),
)
}
/// Sizes the card to `content`, the height its contents came out at, so
/// that all of what was written shows and no more room than that is
/// taken.
fn fit_card(&self, doc: &mut Document, index: usize, content: f32) {
let node = &doc.canvas.nodes[index];
let rect = Rect::from_min_size(
pos2(node.x as f32, node.y as f32),
vec2(node.width as f32, content + 2.0 * NODE_PADDING),
);
let height = (self.snap_rect(rect).height().round() as i32).max(MIN_NODE_SIZE);
if height != node.height {
doc.change("Fit card to text");
doc.canvas.nodes[index].height = height;
} }
true
} }
/// The text an editor on `path` starts from: the whole file, or the lines /// The text an editor on `path` starts from: the whole file, or the lines
@@ -1357,6 +1352,16 @@ impl CanvasView {
StrokeKind::Outside, StrokeKind::Outside,
); );
} }
// The card the editor is open on, so that it is clear where the typing
// in the window lands even when the window sits far from it.
if let Some(node) = self.editing.as_ref().and_then(|id| canvas.node(id)) {
painter.rect_stroke(
node_rect(node).expand(5.0),
CornerRadius::same((NODE_CORNER + 5.0) as u8),
Stroke::new(1.5 / self.to_global.scaling.max(0.2), palette.accent),
StrokeKind::Outside,
);
}
} }
/// Resize and connect handles for the current selection. Returns `true` /// Resize and connect handles for the current selection. Returns `true`
@@ -1591,9 +1596,6 @@ impl CanvasView {
} else if !self.selection.has_node(id) { } else if !self.selection.has_node(id) {
self.selection.set_node(id); self.selection.set_node(id);
} }
if self.editing.as_deref() != Some(id.as_str()) {
self.stop_editing();
}
} }
if let Some(id) = &interactions.double_clicked { if let Some(id) = &interactions.double_clicked {
@@ -1610,7 +1612,6 @@ impl CanvasView {
} else if !self.selection.has_node(id) { } else if !self.selection.has_node(id) {
self.selection.set_node(id); self.selection.set_node(id);
} }
self.stop_editing();
let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone()); let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone());
let origin = ids let origin = ids
.iter() .iter()
@@ -1654,7 +1655,6 @@ impl CanvasView {
if !shift { if !shift {
self.selection.clear(); self.selection.clear();
} }
self.stop_editing();
self.gesture = Gesture::Band { start, add: shift }; self.gesture = Gesture::Band { start, add: shift };
} }
if let Gesture::Band { start, add } = self.gesture { if let Gesture::Band { start, add } = self.gesture {
@@ -1707,7 +1707,6 @@ impl CanvasView {
if !shift { if !shift {
self.selection.clear(); self.selection.clear();
} }
self.stop_editing();
} }
} }
} }
@@ -1927,7 +1926,7 @@ enum Pass {
/// been drawn, because all three commands rearrange the node list. /// been drawn, because all three commands rearrange the node list.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
enum NodeAction { enum NodeAction {
/// Open the in-place editor on the one node the menu was summoned from. /// Open the editor on the one node the menu was summoned from.
Edit, Edit,
SetColor(Option<Color>), SetColor(Option<Color>),
/// `true` brings the selection to the front, `false` sends it to the back. /// `true` brings the selection to the front, `false` sends it to the back.
@@ -2085,32 +2084,124 @@ 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 /// The window the editors live in: outside the canvas layer, so that it is
/// it can show scrolls rather than swallowing the rest, positioned at `offset`. /// never cropped or scaled by the view, and non-modal, so that the card behind
/// /// it stays live. Reports whether it is still open.
/// Only the bar scrolls it by itself. The wheel is routed by fn editor_window(
/// [`CanvasView::scroll_hovered_body`] instead: a scroll area cannot see the ctx: &egui::Context,
/// wheel from in here, because it asks `Context::rect_contains_pointer`, which title: &str,
/// only answers for a layer egui knows as an area — and the canvas layer is a at: Option<Pos2>,
/// bare sublayer. contents: impl FnOnce(&mut egui::Ui) -> EditorPass,
/// Dragging is left out too, so that a drag inside a node moves the node and a ) -> (bool, EditorPass) {
/// touch drag on its text does not scroll instead. let mut open = true;
/// Where a scroll area ended up: the offset it settled on (the bar may have let mut window = egui::Window::new(title)
/// been dragged, or the text cursor may have pulled it along) and how far it // A fixed id, so that the window keeps its size and place when the
/// could still go. // editor moves to another node and the title changes with it.
fn measure<R>(shown: &egui::scroll_area::ScrollAreaOutput<R>) -> BodyScroll { .id(Id::new("node-editor"))
let max = (shown.content_size.y - shown.inner_rect.height()).max(0.0); .open(&mut open)
BodyScroll { .collapsible(false)
offset: shown.state.offset.y.clamp(0.0, max), .resizable(true)
max, .default_size(EDITOR_SIZE)
.min_size(vec2(280.0, 180.0));
// Only on the frame it opens: after that it is wherever it was dragged.
if let Some(at) = at {
window = window.current_pos(at);
} }
let shown = window.show(ctx, contents);
(
open,
shown.and_then(|shown| shown.inner).unwrap_or_default(),
)
} }
fn body_scroll_area(offset: f32) -> egui::ScrollArea { /// Everything one editor needs that is not the text being edited.
struct Editor<'a> {
/// What the text is typed in: the font a text node is read in, or a
/// monospaced one for a file, whose columns have to line up.
font: egui::FontId,
/// Stand-in shown while there is nothing to edit.
hint: &'a str,
/// A line above the text, for saying where the typing goes.
note: Option<&'a str>,
/// Whether to take keyboard focus this frame.
focus: bool,
/// How tall the card's contents came out, when that could be measured.
content_height: Option<f32>,
palette: &'a Palette,
}
/// What one pass of the editor window produced.
#[derive(Debug, Clone, Copy, Default)]
struct EditorPass {
/// The text was typed in this frame.
changed: bool,
/// The card should be sized to fit its contents.
fit: bool,
/// The editor should close.
close: bool,
}
/// The text, filling the window and scrolling when it outgrows it, with the
/// actions along the bottom.
fn editor_contents(ui: &mut egui::Ui, buffer: &mut String, editor: Editor<'_>) -> EditorPass {
let mut pass = EditorPass::default();
// The actions claim the bottom of the window before the text takes
// everything that is left of it.
egui::Panel::bottom("editor-actions").show(ui, |ui| {
ui.add_space(6.0);
ui.horizontal(|ui| {
pass.fit = ui
.add_enabled(
editor.content_height.is_some(),
Button::new("Fit card to text"),
)
.on_hover_text("Size the card to show all of the text, and no more")
.on_disabled_hover_text("Zoom in far enough to see the card to measure it")
.clicked();
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
pass.close = ui.button("Done").clicked();
});
});
ui.add_space(2.0);
});
if let Some(note) = editor.note {
egui::Panel::top("editor-note").show(ui, |ui| {
ui.add_space(2.0);
ui.label(
RichText::new(note)
.size(12.0)
.color(editor.palette.text_muted),
);
ui.add_space(4.0);
});
}
egui::CentralPanel::default().show(ui, |ui| {
// Rows enough to fill the window, so that the whole of it takes the
// typing and not just the lines already written.
let row = ui.fonts_mut(|f| f.row_height(&editor.font));
let rows = ((ui.available_height() / row).floor() as usize).max(4);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.scroll_source(ScrollSource::SCROLL_BAR)
.vertical_scroll_offset(offset)
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| {
let response = ui.add(
TextEdit::multiline(buffer)
.font(editor.font.clone())
.hint_text(editor.hint)
.desired_rows(rows)
.desired_width(f32::INFINITY),
);
if editor.focus {
response.request_focus();
}
pass.changed = response.changed();
// Escape closes the editor, the way it left the one on the card.
pass.close |=
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape));
});
});
pass
} }
/// The side of `rect` closest to `point`. /// The side of `rect` closest to `point`.
@@ -2436,8 +2527,74 @@ mod tests {
(dir, note, doc) (dir, note, doc)
} }
/// The editor offers to grow the card to what was written in it, which
/// means measuring the contents of a card that crops them.
#[test] #[test]
fn a_text_file_node_is_edited_in_place_and_written_back() { fn the_editor_measures_the_card_and_grows_it_to_fit() {
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
// Far more text than a 100x100 card can hold.
doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text {
text: "word ".repeat(120),
};
let mut view = CanvasView::new();
view.edit_node("a");
pass(&ctx, &mut view, &mut doc, screen, vec![]);
let height = view.body_height.expect("the card was never measured");
assert!(
height > 100.0,
"the text measured {height}, which the card already fits"
);
view.fit_card(&mut doc, 0, height);
let grown = doc.canvas.node("a").unwrap().height;
assert!(
grown as f32 >= height,
"the card came out {grown} tall for {height} of text"
);
assert_eq!(doc.undo_label(), Some("Fit card to text"));
}
/// The editor is a window with its own ways out, so the canvas no longer
/// dismisses it: a card can be moved and resized while it is open.
#[test]
fn clicking_the_canvas_leaves_the_editor_open() {
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
view.edit_node("a");
pass(&ctx, &mut view, &mut doc, screen, vec![]);
// Empty canvas, well clear of both nodes and of the window.
let at = pos2(800.0, 700.0);
pass(
&ctx,
&mut view,
&mut doc,
screen,
vec![egui::Event::PointerMoved(at)],
);
pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]);
pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]);
assert!(
view.selection.is_empty(),
"the click never reached the background"
);
assert_eq!(
view.editing.as_deref(),
Some("a"),
"the click closed the editor"
);
}
#[test]
fn a_text_file_node_is_edited_in_the_editor_and_written_back() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
let (dir, note, mut doc) = canvas_with_a_file_node(None); let (dir, note, mut doc) = canvas_with_a_file_node(None);
@@ -2612,6 +2769,52 @@ mod tests {
); );
} }
/// The bare wheel zooms wherever it is turned, cards included, and keeps
/// what is under the pointer under it.
#[test]
fn the_wheel_zooms_over_a_node() {
let ctx = egui::Context::default();
let mut doc = Document::new();
doc.canvas = canvas_with_two_nodes();
let mut view = CanvasView::new();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
// Over node "a", which the identity transform puts at 0..100.
let at = pos2(50.0, 50.0);
let under_pointer = view.to_global.inverse() * at;
pass(
&ctx,
&mut view,
&mut doc,
screen,
vec![egui::Event::PointerMoved(at)],
);
// egui spreads a wheel event over the frames after it, so the zoom
// arrives over several passes.
for _ in 0..10 {
pass(
&ctx,
&mut view,
&mut doc,
screen,
vec![egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 50.0),
modifiers: egui::Modifiers::default(),
phase: egui::TouchPhase::Move,
}],
);
}
assert!(
view.zoom() > 1.0,
"the wheel did not zoom in: {}",
view.zoom()
);
let moved = (view.to_global.inverse() * at).distance(under_pointer);
assert!(moved < 1.0, "the canvas slid {moved} under the pointer");
}
#[test] #[test]
fn a_click_in_the_inspector_stays_in_the_inspector() { fn a_click_in_the_inspector_stays_in_the_inspector() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();