diff --git a/README.md b/README.md
index b226a61..9ad95a7 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,8 @@ reshuffle.
| Gesture | Result |
| --- | --- |
| Double-click empty canvas | New text note, ready to type in |
-| Double-click a note | Edit its Markdown in place |
-| Double-click a file/link node | Open it with the system default application |
+| Double-click a note | Edit its Markdown in an editor window |
+| 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 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 |
@@ -39,8 +39,7 @@ reshuffle.
| Drag on empty canvas | Rubber-band selection (hold Shift to add) |
| Middle- or right-drag | Pan |
| 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 |
-| Ctrl+wheel | Zoom around the pointer |
+| Wheel | Zoom around the pointer, anywhere on the canvas |
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
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
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
-layer, so real widgets (the in-place Markdown editor, links) keep working while
-the canvas is scaled.
+layer, so real widgets (file previews, links) keep working while the canvas is
+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 |
| --- | --- |
diff --git a/src/app.rs b/src/app.rs
index 01ccb44..86f4f13 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1134,7 +1134,7 @@ const SHORTCUTS: &[(&str, &str)] = &[
("Arrow keys", "Nudge (hold Shift for 10 units)"),
("Double-click", "New note, or edit what is under the cursor"),
("Middle / right drag", "Pan the canvas"),
- ("Ctrl+wheel", "Zoom"),
+ ("Wheel", "Zoom around the pointer"),
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),
];
diff --git a/src/inspector.rs b/src/inspector.rs
index 79eb3ab..ab30014 100644
--- a/src/inspector.rs
+++ b/src/inspector.rs
@@ -641,7 +641,7 @@ fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut
for tip in [
"Double-click the canvas to add a note",
"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",
] {
ui.label(
diff --git a/src/view.rs b/src/view.rs
index aad5504..d7c64c5 100644
--- a/src/view.rs
+++ b/src/view.rs
@@ -8,7 +8,7 @@ use std::path::PathBuf;
use eframe::egui::{
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
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;
@@ -40,6 +40,9 @@ const EDGE_HIT_RADIUS: f32 = 8.0;
const HANDLE_SIZE: f32 = 9.0;
const NODE_PADDING: f32 = 10.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
@@ -258,7 +261,7 @@ pub struct CanvasView {
to_global: TSTransform,
pub selection: Selection,
gesture: Gesture,
- /// Node whose text is being edited in place.
+ /// Node the editor is open on.
editing: Option,
/// The text an open file-node editor is working on, while one is open.
file_edit: Option,
@@ -271,8 +274,11 @@ pub struct CanvasView {
pub snap_to_grid: bool,
/// The text of every file the canvas shows, edits included.
pub files: FileStore,
- /// How far each node's contents are scrolled, and how far they can go.
- body_scroll: HashMap,
+ /// How tall the contents of the node being edited came out this frame, so
+ /// 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,
/// 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
/// land where the menu was opened and not where the click on the entry was.
@@ -301,15 +307,6 @@ struct FileEdit {
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
/// has been laid out, its size and position on screen are still a guess.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -340,7 +337,7 @@ impl CanvasView {
connect_target: None,
snap_to_grid: false,
files: FileStore::default(),
- body_scroll: HashMap::new(),
+ body_height: None,
menu_at: None,
viewport: None,
pending_fit: None,
@@ -358,14 +355,14 @@ impl CanvasView {
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) {
self.editing = Some(id.to_owned());
self.focus_editor = true;
}
- /// Starts in-place editing of the file behind a file node, reporting
- /// `false` for a file this editor will not change.
+ /// Opens the editor on the file behind a file node, reporting `false`
+ /// for a file this editor will not change.
pub fn edit_file_contents(&mut self, doc: &Document, id: &str) -> bool {
let can = self.can_edit_file(doc, id);
if can {
@@ -638,13 +635,8 @@ impl CanvasView {
pub fn show(&mut self, ui: &mut egui::Ui, doc: &mut Document, palette: &Palette) -> 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());
- }
+ // Measured again below, by drawing the card the editor is open on.
+ self.body_height = None;
if self
.editing
.as_ref()
@@ -726,16 +718,15 @@ impl CanvasView {
let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas());
background.context_menu(|ui| background_menu(ui, at, &mut output));
- // Last: which node the wheel belongs to is only known once they have
- // all been drawn.
- self.scroll_hovered_body(&scene);
+ // Last: the editor offers to fit the card to its contents, and how
+ // tall those are is only known once the card has been drawn.
+ self.show_edit_window(ui.ctx(), doc, palette);
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.
+ /// Panning is a drag; the wheel zooms, bare or with a 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)
@@ -750,7 +741,13 @@ impl CanvasView {
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
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 {
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
/ 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 ---------------------------------------------------------
fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) {
@@ -922,32 +889,33 @@ impl CanvasView {
if scale >= 0.18 && !is_group {
let inner = rect.shrink(NODE_PADDING);
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(
- UiBuilder::new()
- .id_salt(("body", &id))
- .max_rect(inner)
- .layout(Layout::top_down(Align::Min)),
- );
- body.set_clip_rect(inner.intersect(scene.clip_rect()));
- let mut ctx = BodyContext {
- palette,
- base_dir: base_dir.as_deref(),
- files: &mut self.files,
- scale,
- };
- // 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);
- }
+ let mut body = scene.new_child(
+ UiBuilder::new()
+ .id_salt(("body", &id))
+ .max_rect(inner)
+ .layout(Layout::top_down(Align::Min)),
+ );
+ body.set_clip_rect(inner.intersect(scene.clip_rect()));
+ let mut ctx = BodyContext {
+ palette,
+ base_dir: base_dir.as_deref(),
+ files: &mut self.files,
+ scale,
+ };
+ // Contents taller than the node are cut off by the clip
+ // rectangle: the canvas shows what a card holds, never
+ // less. The editor is where the rest of it is read.
+ if let Some(target) =
+ nodes::render_body(&mut body, &doc.canvas.nodes[index], &mut ctx)
+ {
+ 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
- /// file that is not plain text — so that the caller draws its contents as
- /// usual instead.
- fn show_editor(
- &mut self,
- scene: &mut egui::Ui,
- doc: &mut Document,
- index: usize,
- inner: Rect,
- palette: &Palette,
- ) -> bool {
+ /// A card crops what it shows, so editing on the card cropped the writing
+ /// with it. The window is sized for the text instead: it scrolls, it can
+ /// be as large as the screen, and being non-modal it leaves the canvas
+ /// live behind it — the card can be dragged and resized while it is open,
+ /// and it shows every keystroke as it lands.
+ ///
+ /// A group's label is still edited on the canvas: one line fits there.
+ fn show_edit_window(&mut self, ctx: &egui::Context, doc: &mut Document, palette: &Palette) {
+ let Some(id) = self.editing.clone() else {
+ self.file_edit = None;
+ return;
+ };
+ let Some(index) = doc.canvas.nodes.iter().position(|node| node.id == id) else {
+ self.stop_editing();
+ return;
+ };
match doc.canvas.nodes[index].kind {
- NodeKind::Text { .. } => {
- self.edit_text(scene, doc, index, inner, palette);
- true
- }
- NodeKind::File { .. } => self.edit_file(scene, doc, index, inner, palette),
- _ => {
- self.stop_editing();
- false
- }
+ 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(
&mut self,
- scene: &mut egui::Ui,
+ ctx: &egui::Context,
doc: &mut Document,
index: usize,
- inner: Rect,
palette: &Palette,
) {
let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else {
return;
};
let mut buffer = text.clone();
- 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()));
- // 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\u{2026}").color(palette.text_muted))
- .font(egui::FontId::proportional(14.0)),
- )
+ let fits = self.body_height;
+ let focus = std::mem::take(&mut self.focus_editor);
+ let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
+ let editor = Editor {
+ font: egui::FontId::proportional(14.0),
+ hint: "Markdown\u{2026}",
+ note: None,
+ focus,
+ content_height: fits,
+ palette,
+ };
+ let (open, pass) = editor_window(ctx, "Edit text", at, |ui| {
+ editor_contents(ui, &mut buffer, editor)
});
- self.body_scroll.insert(id.clone(), measure(&shown));
- let response = shown.inner;
- if self.focus_editor {
- response.request_focus();
- self.focus_editor = false;
- }
- if response.changed() {
+
+ if pass.changed {
doc.begin_change("Edit text");
if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind {
*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();
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
- /// typing in a node never touches the disk on its own. Returns `false` for
- /// a file this editor will not change; see [`CanvasView::can_edit_file`].
+ /// typing in a node never touches the disk on its own. A file this editor
+ /// will not change closes it again; see [`CanvasView::can_edit_file`].
fn edit_file(
&mut self,
- scene: &mut egui::Ui,
+ ctx: &egui::Context,
doc: &mut Document,
index: usize,
- inner: Rect,
palette: &Palette,
- ) -> bool {
+ ) {
let node = &doc.canvas.nodes[index];
let NodeKind::File { file, subpath } = &node.kind else {
- return false;
+ return;
};
let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone());
let path = nodes::resolve_path(doc.base_dir().as_deref(), &file);
@@ -1213,89 +1181,116 @@ impl CanvasView {
{
let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else {
self.stop_editing();
- return false;
+ return;
};
self.file_edit = Some(FileEdit {
- id: id.clone(),
+ id,
path: path.clone(),
section,
buffer,
});
}
- let Self {
- editing,
- file_edit,
- focus_editor,
- body_scroll,
- files,
- ..
- } = self;
- let Some(edit) = file_edit.as_mut() else {
- return false;
+ let title = match &subpath {
+ Some(subpath) => format!("Edit {} {subpath}", crate::model::file_name(&file)),
+ None => format!("Edit {}", crate::model::file_name(&file)),
+ };
+ // Said plainly, because this is the one editor whose typing lands
+ // somewhere other than the canvas.
+ let note = format!("Writes to {file}, and is written out when the canvas is saved");
+ let fits = self.body_height;
+ let focus = std::mem::take(&mut self.focus_editor);
+ let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
+ let editor = Editor {
+ font: egui::FontId::monospace(13.0),
+ hint: "",
+ note: Some(¬e),
+ focus,
+ content_height: fits,
+ palette,
};
- let mut body = scene.new_child(
- UiBuilder::new()
- .id_salt(("editor", &id))
- .max_rect(inner)
- .layout(Layout::top_down(Align::Min)),
- );
- body.set_clip_rect(inner.intersect(scene.clip_rect()));
- // A header, so that it is clear the typing goes into the file on disk
- // and not into the canvas.
- body.horizontal(|ui| {
- ui.spacing_mut().item_spacing.x = 4.0;
- 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);
-
- 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() {
- // A node showing one section writes that section back into the
- // file around it, leaving the rest of it alone.
- Some(range) => {
- if let Ok(loaded) = files.entry(&edit.path) {
- let (text, moved) = files::splice_lines(&loaded.text, range, &edit.buffer);
- edit.section = Some(moved);
- files.set(&edit.path, text);
+ // The buffer and the store are both borrowed out of `self` here, so
+ // everything that needs the view whole waits until after this block.
+ let (open, pass) = {
+ let Self {
+ file_edit, files, ..
+ } = self;
+ let Some(edit) = file_edit.as_mut() else {
+ return;
+ };
+ let (open, pass) = editor_window(ctx, &title, at, |ui| {
+ editor_contents(ui, &mut edit.buffer, editor)
+ });
+ if pass.changed {
+ match edit.section.clone() {
+ // A node showing one section writes that section back into
+ // the file around it, leaving the rest of it alone.
+ Some(range) => {
+ if let Ok(loaded) = files.entry(&edit.path) {
+ let (text, moved) =
+ files::splice_lines(&loaded.text, range, &edit.buffer);
+ edit.section = Some(moved);
+ files.set(&edit.path, text);
+ }
}
+ None => files.set(&edit.path, edit.buffer.clone()),
}
- None => files.set(&edit.path, edit.buffer.clone()),
}
+ (open, pass)
+ };
+
+ if pass.fit
+ && let Some(height) = fits
+ {
+ self.fit_card(doc, index, height);
}
- if response.lost_focus() {
- *editing = None;
- *file_edit = None;
+ 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
@@ -1357,6 +1352,16 @@ impl CanvasView {
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`
@@ -1591,9 +1596,6 @@ impl CanvasView {
} else if !self.selection.has_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 {
@@ -1610,7 +1612,6 @@ impl CanvasView {
} else if !self.selection.has_node(id) {
self.selection.set_node(id);
}
- self.stop_editing();
let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone());
let origin = ids
.iter()
@@ -1654,7 +1655,6 @@ impl CanvasView {
if !shift {
self.selection.clear();
}
- self.stop_editing();
self.gesture = Gesture::Band { start, add: shift };
}
if let Gesture::Band { start, add } = self.gesture {
@@ -1707,7 +1707,6 @@ impl CanvasView {
if !shift {
self.selection.clear();
}
- self.stop_editing();
}
}
}
@@ -1927,7 +1926,7 @@ enum Pass {
/// been drawn, because all three commands rearrange the node list.
#[derive(Debug, Clone, PartialEq)]
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,
SetColor(Option),
/// `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
-/// 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,
+/// The window the editors live in: outside the canvas layer, so that it is
+/// never cropped or scaled by the view, and non-modal, so that the card behind
+/// it stays live. Reports whether it is still open.
+fn editor_window(
+ ctx: &egui::Context,
+ title: &str,
+ at: Option,
+ contents: impl FnOnce(&mut egui::Ui) -> EditorPass,
+) -> (bool, EditorPass) {
+ let mut open = true;
+ let mut window = egui::Window::new(title)
+ // A fixed id, so that the window keeps its size and place when the
+ // editor moves to another node and the title changes with it.
+ .id(Id::new("node-editor"))
+ .open(&mut open)
+ .collapsible(false)
+ .resizable(true)
+ .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 {
- egui::ScrollArea::vertical()
- .scroll_source(ScrollSource::SCROLL_BAR)
- .vertical_scroll_offset(offset)
- .auto_shrink([false, false])
+/// 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,
+ 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()
+ .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`.
@@ -2436,8 +2527,74 @@ mod tests {
(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]
- 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 screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0));
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]
fn a_click_in_the_inspector_stays_in_the_inspector() {
let ctx = egui::Context::default();