Popout editor for all cards

This commit is contained in:
2026-09-10 21:04:15 +02:00
parent 28559cfd5d
commit 446bb24237
4 changed files with 272 additions and 63 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ reshuffle.
| Double-click a note | Edit its Markdown in an editor window | | 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 | | 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 | 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 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 corner or side handle | Resize |
| Drag a group | Moves the group and everything inside it | | Drag a group | Moves the group and everything inside it |
+9 -2
View File
@@ -12,7 +12,7 @@ use crate::geometry::{bounds_of, node_rect};
use crate::inspector; use crate::inspector;
use crate::model::{Canvas, Node, NodeKind}; use crate::model::{Canvas, Node, NodeKind};
use crate::theme::Palette; 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. /// How long a status message stays on screen.
const STATUS_SECONDS: f64 = 5.0; const STATUS_SECONDS: f64 = 5.0;
@@ -546,8 +546,12 @@ impl App {
} }
if ctx.input_mut(|i| i.key_pressed(Key::F2)) if ctx.input_mut(|i| i.key_pressed(Key::F2))
&& let Some(id) = self.view.selection.only_node().map(str::to_owned) && 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() { if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() {
self.view.zoom_to_selection(&self.doc.canvas); self.view.zoom_to_selection(&self.doc.canvas);
@@ -1112,6 +1116,9 @@ impl eframe::App for App {
if let Some((kind, at)) = out.insert { if let Some((kind, at)) = out.insert {
self.insert_requested(kind, at, &ctx); self.insert_requested(kind, at, &ctx);
} }
if out.reveal_inspector {
self.show_inspector = true;
}
}); });
self.dialogs(&ctx); self.dialogs(&ctx);
+3 -3
View File
@@ -101,11 +101,11 @@ fn node_section(
{ {
out.open_target = Some(file.clone()); out.open_target = Some(file.clone());
} }
// Editing happens on the canvas, in the node itself; this is // Editing happens in a window of its own; this is the way in
// the way in for anyone who has not found the double-click. // for anyone who has not found the double-click.
if ui if ui
.button("Edit") .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() .clicked()
{ {
out.status = Some(if view.edit_file_contents(doc, id) { out.status = Some(if view.edit_file_contents(doc, id) {
+259 -57
View File
@@ -250,6 +250,18 @@ pub struct ViewOutput {
pub status: Option<String>, pub status: Option<String>,
/// A node to add, and where in the canvas it goes. /// A node to add, and where in the canvas it goes.
pub insert: Option<(InsertRequest, Pos2)>, 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, 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 tall the contents of the node being edited came out this frame, so /// How tall each card's contents came out the last time it was drawn, so
/// that the editor can offer to grow the card to fit them. `None` while /// that a card can be sized to fit them. Cards drawn too small to measure
/// nothing is being edited, or while the card is drawn too small to /// are left out; the rest are one frame behind at most.
/// measure. body_heights: HashMap<String, f32>,
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.
@@ -337,7 +348,7 @@ impl CanvasView {
connect_target: None, connect_target: None,
snap_to_grid: false, snap_to_grid: false,
files: FileStore::default(), files: FileStore::default(),
body_height: None, body_heights: HashMap::new(),
menu_at: None, menu_at: None,
viewport: None, viewport: None,
pending_fit: None, pending_fit: None,
@@ -361,6 +372,32 @@ impl CanvasView {
self.focus_editor = true; 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` /// Opens the editor on the file behind a file node, reporting `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 {
@@ -635,8 +672,13 @@ 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);
// Measured again below, by drawing the card the editor is open on. // Cards that are gone leave a measurement behind. The map only ever
self.body_height = None; // 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 if self
.editing .editing
.as_ref() .as_ref()
@@ -896,6 +938,10 @@ impl CanvasView {
.layout(Layout::top_down(Align::Min)), .layout(Layout::top_down(Align::Min)),
); );
body.set_clip_rect(inner.intersect(scene.clip_rect())); 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 { let mut ctx = BodyContext {
palette, palette,
base_dir: base_dir.as_deref(), base_dir: base_dir.as_deref(),
@@ -910,12 +956,13 @@ impl CanvasView {
{ {
output.open_target = Some(target); output.open_target = Some(target);
} }
// What the editor would have to grow the card to. Detail // What fitting the card would size it to. Detail is
// is dropped from a card drawn small (see // dropped from a card drawn small (see
// `nodes::render_body`), and with it any hope of measuring // `nodes::render_body`), and with it any hope of measuring
// one. // one.
if self.editing.as_deref() == Some(id.as_str()) && scale >= 0.4 { if scale >= 0.4 {
self.body_height = Some(body.min_rect().height()); 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 // The menu only records what was asked for. Deleting or reordering
// nodes here would pull the canvas out from under this very loop, // nodes here would pull the canvas out from under this very loop,
// so `handle_interactions` carries the action out afterwards. // so `handle_interactions` carries the action out afterwards.
let can_fit = self.can_fit_selection();
response.context_menu(|ui| { 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( node_menu(
ui, ui,
palette, palette,
color.as_ref(), color.as_ref(),
selected, selected,
edit, can_fit,
&mut interactions.menu, &mut interactions.menu,
); );
}); });
@@ -1120,7 +1164,7 @@ impl CanvasView {
return; return;
}; };
let mut buffer = text.clone(); 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 focus = std::mem::take(&mut self.focus_editor);
let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
let editor = Editor { let editor = Editor {
@@ -1184,7 +1228,7 @@ impl CanvasView {
return; return;
}; };
self.file_edit = Some(FileEdit { self.file_edit = Some(FileEdit {
id, id: id.clone(),
path: path.clone(), path: path.clone(),
section, section,
buffer, buffer,
@@ -1198,7 +1242,7 @@ impl CanvasView {
// Said plainly, because this is the one editor whose typing lands // Said plainly, because this is the one editor whose typing lands
// somewhere other than the canvas. // somewhere other than the canvas.
let note = format!("Writes to {file}, and is written out when the canvas is saved"); 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 focus = std::mem::take(&mut self.focus_editor);
let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index])); let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
let editor = Editor { let editor = Editor {
@@ -1257,14 +1301,12 @@ impl CanvasView {
const GAP: f32 = 16.0; const GAP: f32 = 16.0;
let card = self.to_global * node_rect(node); let card = self.to_global * node_rect(node);
let view = self.viewport(); let view = self.viewport();
let right = card.right() + GAP; // Beside the card on whichever side has more room for it; when
let left = card.left() - GAP - EDITOR_SIZE.x; // neither side has enough, that is the side it covers least of.
// Beside the card on whichever side has the room, and failing both, let x = if view.right() - card.right() >= card.left() - view.left() {
// wherever it fits. card.right() + GAP
let x = if right + EDITOR_SIZE.x <= view.right() {
right
} else { } else {
left card.left() - GAP - EDITOR_SIZE.x
}; };
let fit = |v: f32, min: f32, max: f32| v.clamp(min, max.max(min)); let fit = |v: f32, min: f32, max: f32| v.clamp(min, max.max(min));
pos2( pos2(
@@ -1288,11 +1330,41 @@ impl CanvasView {
); );
let height = (self.snap_rect(rect).height().round() as i32).max(MIN_NODE_SIZE); let height = (self.snap_rect(rect).height().round() as i32).max(MIN_NODE_SIZE);
if height != node.height { 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; 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<String> {
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 text an editor on `path` starts from: the whole file, or the lines
/// the section `subpath` names covers. /// the section `subpath` names covers.
/// ///
@@ -1733,10 +1805,14 @@ impl CanvasView {
} }
match interactions.menu { match interactions.menu {
Some(NodeAction::Edit) => { Some(NodeAction::Edit) => {
if let Some(id) = self.selection.only_node().map(str::to_owned) { if let Some(id) = self.selection.only_node().map(str::to_owned)
self.edit_node(&id); && 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::SetColor(color)) => self.set_selection_color(doc, color),
Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front), Some(NodeAction::Raise(to_front)) => self.raise_selection(doc, to_front),
Some(NodeAction::Delete) => output.status = self.delete_selection(doc), Some(NodeAction::Delete) => output.status = self.delete_selection(doc),
@@ -1928,6 +2004,8 @@ enum Pass {
enum NodeAction { enum NodeAction {
/// Open the editor on the one node the menu was summoned from. /// Open the editor on the one node the menu was summoned from.
Edit, Edit,
/// Size every card in the selection to its contents.
Fit,
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.
Raise(bool), Raise(bool),
@@ -1986,22 +2064,29 @@ fn node_menu(
palette: &Palette, palette: &Palette,
current: Option<&Color>, current: Option<&Color>,
selected: usize, selected: usize,
edit: bool, can_fit: bool,
action: &mut Option<NodeAction>, action: &mut Option<NodeAction>,
) { ) {
// Double-clicking a file node does this too, which is not something the // Double-clicking does this too, which is not something a node gives away.
// node itself gives away. if selected <= 1
if edit { && ui
if ui .button("Edit")
.button("Edit contents") .on_hover_text("Open the editor on this node")
.on_hover_text("Change the file itself, here on the canvas")
.clicked() .clicked()
{ {
*action = Some(NodeAction::Edit); *action = Some(NodeAction::Edit);
ui.close(); ui.close();
}
ui.separator();
} }
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| { ui.menu_button("Colour", |ui| {
for (index, name) in Color::PRESET_NAMES.iter().enumerate() { for (index, name) in Color::PRESET_NAMES.iter().enumerate() {
let preset = Color::Preset(index as u8 + 1); 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. // everything that is left of it.
egui::Panel::bottom("editor-actions").show(ui, |ui| { egui::Panel::bottom("editor-actions").show(ui, |ui| {
ui.add_space(6.0); 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| { 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| { 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); ui.add_space(2.0);
@@ -2527,10 +2614,10 @@ mod tests {
(dir, note, doc) (dir, note, doc)
} }
/// The editor offers to grow the card to what was written in it, which /// Fitting a card means measuring contents the card itself crops, and
/// means measuring the contents of a card that crops them. /// fitting several of them is one thing done, not one per card.
#[test] #[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 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 mut doc = Document::new(); let mut doc = Document::new();
@@ -2543,19 +2630,134 @@ mod tests {
view.edit_node("a"); view.edit_node("a");
pass(&ctx, &mut view, &mut doc, screen, vec![]); 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!( assert!(
height > 100.0, height > 100.0,
"the text measured {height}, which the card already fits" "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; let grown = doc.canvas.node("a").unwrap().height;
assert!( assert!(
grown as f32 >= height, grown as f32 >= height,
"the card came out {grown} tall for {height} of text" "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 /// The editor is a window with its own ways out, so the canvas no longer