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();