From 446bb2423747a6ecbbcd78a74e44ea318aef83cb Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 10 Sep 2026 21:04:15 +0200 Subject: [PATCH] Popout editor for all cards --- README.md | 2 +- src/app.rs | 11 +- src/inspector.rs | 6 +- src/view.rs | 316 ++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 272 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 9ad95a7..fc688f3 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ reshuffle. | 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 | +| Right-click a node | Edit, fit the card to its text, 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 corner or side handle | Resize | | Drag a group | Moves the group and everything inside it | diff --git a/src/app.rs b/src/app.rs index 86f4f13..216e25f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,7 @@ use crate::geometry::{bounds_of, node_rect}; use crate::inspector; use crate::model::{Canvas, Node, NodeKind}; use crate::theme::Palette; -use crate::view::{CanvasView, Clipboard, InsertRequest}; +use crate::view::{CanvasView, Clipboard, EditTarget, InsertRequest}; /// How long a status message stays on screen. const STATUS_SECONDS: f64 = 5.0; @@ -546,8 +546,12 @@ impl App { } if ctx.input_mut(|i| i.key_pressed(Key::F2)) && let Some(id) = self.view.selection.only_node().map(str::to_owned) + && let EditTarget::Inspector(reason) = self.view.begin_edit(&self.doc, &id) { - self.view.edit_node(&id); + // Nothing to type into, so the fields that can be changed are put + // where they are instead. + self.show_inspector = true; + self.set_status(reason, ctx); } if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() { self.view.zoom_to_selection(&self.doc.canvas); @@ -1112,6 +1116,9 @@ impl eframe::App for App { if let Some((kind, at)) = out.insert { self.insert_requested(kind, at, &ctx); } + if out.reveal_inspector { + self.show_inspector = true; + } }); self.dialogs(&ctx); diff --git a/src/inspector.rs b/src/inspector.rs index ab30014..a5cb163 100644 --- a/src/inspector.rs +++ b/src/inspector.rs @@ -101,11 +101,11 @@ fn node_section( { out.open_target = Some(file.clone()); } - // Editing happens on the canvas, in the node itself; this is - // the way in for anyone who has not found the double-click. + // Editing happens in a window of its own; this is the way in + // for anyone who has not found the double-click. if ui .button("Edit") - .on_hover_text("Change the file itself, in the node on the canvas") + .on_hover_text("Change the file itself, in an editor window") .clicked() { out.status = Some(if view.edit_file_contents(doc, id) { diff --git a/src/view.rs b/src/view.rs index d7c64c5..80ef01e 100644 --- a/src/view.rs +++ b/src/view.rs @@ -250,6 +250,18 @@ pub struct ViewOutput { pub status: Option, /// A node to add, and where in the canvas it goes. pub insert: Option<(InsertRequest, Pos2)>, + /// Whether to show the inspector, for a node whose fields live there. + pub reveal_inspector: bool, +} + +/// Where an edit request landed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditTarget { + /// The editor took it: a window, or a group's label on the canvas. + Editor, + /// The node has nothing to type into. The message says where its fields + /// are instead. + Inspector(&'static str), } // --------------------------------------------------------------------------- @@ -274,11 +286,10 @@ pub struct CanvasView { pub snap_to_grid: bool, /// The text of every file the canvas shows, edits included. pub files: FileStore, - /// 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, + /// How tall each card's contents came out the last time it was drawn, so + /// that a card can be sized to fit them. Cards drawn too small to measure + /// are left out; the rest are one frame behind at most. + body_heights: HashMap, /// 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. @@ -337,7 +348,7 @@ impl CanvasView { connect_target: None, snap_to_grid: false, files: FileStore::default(), - body_height: None, + body_heights: HashMap::new(), menu_at: None, viewport: None, pending_fit: None, @@ -361,6 +372,32 @@ impl CanvasView { self.focus_editor = true; } + /// Takes an edit request on any node, reporting where the editing has to + /// happen: here, or in the inspector for a node with no text in it. + pub fn begin_edit(&mut self, doc: &Document, id: &str) -> EditTarget { + let Some(node) = doc.canvas.node(id) else { + return EditTarget::Editor; + }; + let reason = match &node.kind { + NodeKind::Text { .. } | NodeKind::Group { .. } => None, + NodeKind::File { .. } if self.can_edit_file(doc, id) => None, + NodeKind::File { .. } => Some( + "Only plain-text files are edited here \u{2014} the file and subpath are in the inspector", + ), + NodeKind::Link { .. } => Some("A link node's address and label are in the inspector"), + NodeKind::Unknown { .. } => { + Some("This node type is not one this editor knows \u{2014} it is kept as it stands") + } + }; + match reason { + Some(reason) => EditTarget::Inspector(reason), + None => { + self.edit_node(id); + EditTarget::Editor + } + } + } + /// 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 { @@ -635,8 +672,13 @@ 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); - // Measured again below, by drawing the card the editor is open on. - self.body_height = None; + // Cards that are gone leave a measurement behind. The map only ever + // holds cards that have been drawn, so it outgrows the canvas only + // once one of them is deleted. + if self.body_heights.len() > doc.canvas.nodes.len() { + self.body_heights + .retain(|id, _| doc.canvas.node(id).is_some()); + } if self .editing .as_ref() @@ -896,6 +938,10 @@ impl CanvasView { .layout(Layout::top_down(Align::Min)), ); body.set_clip_rect(inner.intersect(scene.clip_rect())); + // A card is dragged and double-clicked, so its text is not + // there to be selected: egui's selectable labels would eat + // both gestures wherever a line of text lies under them. + body.style_mut().interaction.selectable_labels = false; let mut ctx = BodyContext { palette, base_dir: base_dir.as_deref(), @@ -910,12 +956,13 @@ impl CanvasView { { output.open_target = Some(target); } - // What the editor would have to grow the card to. Detail - // is dropped from a card drawn small (see + // What fitting the card would size it 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()); + if scale >= 0.4 { + self.body_heights + .insert(id.clone(), body.min_rect().height()); } } } @@ -953,17 +1000,14 @@ impl CanvasView { // The menu only records what was asked for. Deleting or reordering // nodes here would pull the canvas out from under this very loop, // so `handle_interactions` carries the action out afterwards. + let can_fit = self.can_fit_selection(); response.context_menu(|ui| { - // Whether the file behind the node can be edited is only worth - // working out once the menu is open, because finding out reads - // the file. - let edit = selected <= 1 && self.can_edit_file(doc, &id); node_menu( ui, palette, color.as_ref(), selected, - edit, + can_fit, &mut interactions.menu, ); }); @@ -1120,7 +1164,7 @@ impl CanvasView { return; }; let mut buffer = text.clone(); - let fits = self.body_height; + let fits = self.body_heights.get(&doc.canvas.nodes[index].id).copied(); let focus = std::mem::take(&mut self.focus_editor); let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); let editor = Editor { @@ -1184,7 +1228,7 @@ impl CanvasView { return; }; self.file_edit = Some(FileEdit { - id, + id: id.clone(), path: path.clone(), section, buffer, @@ -1198,7 +1242,7 @@ impl CanvasView { // 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 fits = self.body_heights.get(&id).copied(); let focus = std::mem::take(&mut self.focus_editor); let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); let editor = Editor { @@ -1257,14 +1301,12 @@ impl CanvasView { 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 + // Beside the card on whichever side has more room for it; when + // neither side has enough, that is the side it covers least of. + let x = if view.right() - card.right() >= card.left() - view.left() { + card.right() + GAP } else { - left + card.left() - GAP - EDITOR_SIZE.x }; let fit = |v: f32, min: f32, max: f32| v.clamp(min, max.max(min)); pos2( @@ -1288,11 +1330,41 @@ impl CanvasView { ); let height = (self.snap_rect(rect).height().round() as i32).max(MIN_NODE_SIZE); if height != node.height { - doc.change("Fit card to text"); + // Left open, so that fitting several cards at once is one step to + // undo; every caller closes it. + doc.begin_change("Fit to text"); doc.canvas.nodes[index].height = height; } } + /// Sizes every measured card in the selection to its contents, reporting + /// what stood in the way when none of them could be. + fn fit_selection(&self, doc: &mut Document) -> Option { + let mut fitted = 0; + for id in self.selection.nodes.clone() { + let Some(content) = self.body_heights.get(&id).copied() else { + continue; + }; + let Some(index) = doc.canvas.nodes.iter().position(|node| node.id == id) else { + continue; + }; + self.fit_card(doc, index, content); + fitted += 1; + } + doc.end_change(); + (fitted == 0) + .then(|| "Zoom in far enough for the cards to be drawn, then fit them".to_owned()) + } + + /// Whether anything in the selection is a card that could be fitted: a + /// group has no contents to fit, and nor has a card too small to measure. + fn can_fit_selection(&self) -> bool { + self.selection + .nodes + .iter() + .any(|id| self.body_heights.contains_key(id)) + } + /// The text an editor on `path` starts from: the whole file, or the lines /// the section `subpath` names covers. /// @@ -1733,10 +1805,14 @@ impl CanvasView { } match interactions.menu { Some(NodeAction::Edit) => { - if let Some(id) = self.selection.only_node().map(str::to_owned) { - self.edit_node(&id); + if let Some(id) = self.selection.only_node().map(str::to_owned) + && let EditTarget::Inspector(reason) = self.begin_edit(doc, &id) + { + output.reveal_inspector = true; + output.status = Some(reason.to_owned()); } } + Some(NodeAction::Fit) => output.status = self.fit_selection(doc), Some(NodeAction::SetColor(color)) => self.set_selection_color(doc, color), Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front), Some(NodeAction::Delete) => output.status = self.delete_selection(doc), @@ -1928,6 +2004,8 @@ enum Pass { enum NodeAction { /// Open the editor on the one node the menu was summoned from. Edit, + /// Size every card in the selection to its contents. + Fit, SetColor(Option), /// `true` brings the selection to the front, `false` sends it to the back. Raise(bool), @@ -1986,22 +2064,29 @@ fn node_menu( palette: &Palette, current: Option<&Color>, selected: usize, - edit: bool, + can_fit: bool, action: &mut Option, ) { - // Double-clicking a file node does this too, which is not something the - // node itself gives away. - if edit { - if ui - .button("Edit contents") - .on_hover_text("Change the file itself, here on the canvas") + // Double-clicking does this too, which is not something a node gives away. + if selected <= 1 + && ui + .button("Edit") + .on_hover_text("Open the editor on this node") .clicked() - { - *action = Some(NodeAction::Edit); - ui.close(); - } - ui.separator(); + { + *action = Some(NodeAction::Edit); + ui.close(); } + if ui + .add_enabled(can_fit, Button::new("Fit 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 for the cards to be drawn") + .clicked() + { + *action = Some(NodeAction::Fit); + ui.close(); + } + ui.separator(); ui.menu_button("Colour", |ui| { for (index, name) in Color::PRESET_NAMES.iter().enumerate() { let preset = Color::Preset(index as u8 + 1); @@ -2150,17 +2235,19 @@ fn editor_contents(ui: &mut egui::Ui, buffer: &mut String, editor: Editor<'_>) - // everything that is left of it. egui::Panel::bottom("editor-actions").show(ui, |ui| { ui.add_space(6.0); + // The row of a horizontal layout, so that the right-to-left one inside + // it is measured against a line of buttons and not the whole window. 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(); + pass.close = ui.button("Close").clicked(); + // The card crops what it cannot show, so the way out that + // leaves nothing hidden sits right beside the plain one. + pass.fit = ui + .add_enabled(editor.content_height.is_some(), Button::new("Close & Fit")) + .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(); + pass.close |= pass.fit; }); }); ui.add_space(2.0); @@ -2527,10 +2614,10 @@ 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. + /// Fitting a card means measuring contents the card itself crops, and + /// fitting several of them is one thing done, not one per card. #[test] - fn the_editor_measures_the_card_and_grows_it_to_fit() { + fn cards_are_measured_and_fitted_to_their_text_in_one_step() { let ctx = egui::Context::default(); let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); let mut doc = Document::new(); @@ -2543,19 +2630,134 @@ mod tests { view.edit_node("a"); pass(&ctx, &mut view, &mut doc, screen, vec![]); - let height = view.body_height.expect("the card was never measured"); + let height = view + .body_heights + .get("a") + .copied() + .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); + // Both cards at once, the way the menu fits a whole selection. + view.selection.set_node("a"); + view.selection.nodes.push("b".to_owned()); + assert_eq!(view.fit_selection(&mut doc), None); 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")); + + // And the two of them are one step to undo, not two. + assert_eq!(doc.undo(), Some("Fit to text".to_owned())); + assert_eq!(doc.canvas.node("a").unwrap().height, 100); + assert_eq!(doc.canvas.node("b").unwrap().height, 100); + assert!(!doc.can_undo(), "fitting left more than one step behind"); + } + + /// A double click on a card opens the editor wherever it lands — the + /// text of a card is not there to be selected, and egui's selectable + /// labels would take the gesture for a word. + #[test] + fn a_double_click_on_a_card_s_text_opens_the_editor() { + 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(); + doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text { + text: "some words to land on".to_owned(), + }; + let mut view = CanvasView::new(); + + // The first line of the card's text, just inside the padding. + let at = pos2(30.0, 20.0); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + for _ in 0..2 { + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + } + + assert_eq!(view.editing.as_deref(), Some("a")); + } + + /// The editor's ways out sit in a strip along the bottom of the window, + /// with the text taking everything above them. + #[test] + fn the_editor_keeps_its_actions_in_a_strip_at_the_bottom() { + 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"); + // Twice: the window is laid out against the rectangles of the pass + // before it. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + let window = ctx + .memory(|m| m.area_rect(Id::new("node-editor"))) + .expect("the editor window was never laid out"); + let actions = egui::containers::panel::PanelState::load(&ctx, Id::new("editor-actions")) + .expect("the editor's actions were never laid out") + .outer_rect; + assert!( + actions.height() < window.height() / 3.0, + "the actions took {} of a {} window", + actions.height(), + window.height() + ); + assert!( + actions.bottom() >= window.bottom() - 8.0, + "the actions sit at {}, not at the bottom of the window ({})", + actions.bottom(), + window.bottom() + ); + + // And the plain way out is where a user would aim for it: the far end + // of that strip. + let at = pos2(actions.right() - 30.0, actions.center().y); + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + assert_eq!(view.editing, None, "the button at {at:?} did not close it"); + } + + /// Every node takes an edit request; the ones with no text in them say + /// where their fields are instead of doing nothing. + #[test] + fn an_edit_request_on_a_node_with_no_text_points_at_the_inspector() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.nodes.push(Node::new( + "l", + NodeKind::Link { + url: "https://example.com".to_owned(), + label: None, + }, + 0, + 300, + 200, + 60, + )); + let mut view = CanvasView::new(); + + assert_eq!(view.begin_edit(&doc, "a"), EditTarget::Editor); + assert_eq!(view.editing.as_deref(), Some("a")); + + let EditTarget::Inspector(reason) = view.begin_edit(&doc, "l") else { + panic!("the editor took a link node, which has nothing to type into"); + }; + assert!(reason.contains("inspector"), "unhelpful reason: {reason:?}"); + // And the editor it could not take the node into is left as it was. + assert_eq!(view.editing.as_deref(), Some("a")); } /// The editor is a window with its own ways out, so the canvas no longer