Rework Scrolling and Zooming
This commit is contained in:
@@ -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 <kbd>Shift</kbd> 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 |
|
||||
| <kbd>Ctrl</kbd>+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 |
|
||||
| --- | --- |
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+418
-215
@@ -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<String>,
|
||||
/// The text an open file-node editor is working on, while one is open.
|
||||
file_edit: Option<FileEdit>,
|
||||
@@ -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<String, BodyScroll>,
|
||||
/// 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<f32>,
|
||||
/// 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,9 +889,6 @@ 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))
|
||||
@@ -938,16 +902,20 @@ impl CanvasView {
|
||||
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 {
|
||||
// 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 {
|
||||
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),
|
||||
_ => {
|
||||
/// 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();
|
||||
false
|
||||
}
|
||||
return;
|
||||
};
|
||||
match doc.canvas.nodes[index].kind {
|
||||
NodeKind::Text { .. } => self.edit_text(ctx, doc, index, palette),
|
||||
NodeKind::File { .. } => self.edit_file(ctx, doc, index, palette),
|
||||
NodeKind::Group { .. } => {}
|
||||
// Nothing in a link or an unknown node is typed in here.
|
||||
_ => self.stop_editing(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-place editor for a text node, which writes into the canvas.
|
||||
/// The editor for a text node, which writes into the canvas.
|
||||
fn edit_text(
|
||||
&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,77 +1181,55 @@ 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));
|
||||
}
|
||||
// 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)
|
||||
});
|
||||
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() {
|
||||
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.
|
||||
// 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);
|
||||
let (text, moved) =
|
||||
files::splice_lines(&loaded.text, range, &edit.buffer);
|
||||
edit.section = Some(moved);
|
||||
files.set(&edit.path, text);
|
||||
}
|
||||
@@ -1291,11 +1237,60 @@ impl CanvasView {
|
||||
None => files.set(&edit.path, edit.buffer.clone()),
|
||||
}
|
||||
}
|
||||
if response.lost_focus() {
|
||||
*editing = None;
|
||||
*file_edit = None;
|
||||
(open, pass)
|
||||
};
|
||||
|
||||
if pass.fit
|
||||
&& let Some(height) = fits
|
||||
{
|
||||
self.fit_card(doc, index, height);
|
||||
}
|
||||
if pass.close || !open {
|
||||
self.stop_editing();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the editor window goes when it opens on `node`: beside the card,
|
||||
/// so that neither hides the other, and within the viewport. It stays
|
||||
/// wherever it is dragged to from there, until it opens on another card.
|
||||
fn editor_pos(&self, node: &Node) -> Pos2 {
|
||||
const GAP: f32 = 16.0;
|
||||
let card = self.to_global * node_rect(node);
|
||||
let view = self.viewport();
|
||||
let right = card.right() + GAP;
|
||||
let left = card.left() - GAP - EDITOR_SIZE.x;
|
||||
// Beside the card on whichever side has the room, and failing both,
|
||||
// wherever it fits.
|
||||
let x = if right + EDITOR_SIZE.x <= view.right() {
|
||||
right
|
||||
} else {
|
||||
left
|
||||
};
|
||||
let fit = |v: f32, min: f32, max: f32| v.clamp(min, max.max(min));
|
||||
pos2(
|
||||
fit(x, view.left() + GAP, view.right() - EDITOR_SIZE.x - GAP),
|
||||
fit(
|
||||
card.top(),
|
||||
view.top() + GAP,
|
||||
view.bottom() - EDITOR_SIZE.y - GAP,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Sizes the card to `content`, the height its contents came out at, so
|
||||
/// that all of what was written shows and no more room than that is
|
||||
/// taken.
|
||||
fn fit_card(&self, doc: &mut Document, index: usize, content: f32) {
|
||||
let node = &doc.canvas.nodes[index];
|
||||
let rect = Rect::from_min_size(
|
||||
pos2(node.x as f32, node.y as f32),
|
||||
vec2(node.width as f32, content + 2.0 * NODE_PADDING),
|
||||
);
|
||||
let height = (self.snap_rect(rect).height().round() as i32).max(MIN_NODE_SIZE);
|
||||
if height != node.height {
|
||||
doc.change("Fit card to text");
|
||||
doc.canvas.nodes[index].height = height;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The text an editor on `path` starts from: the whole file, or the lines
|
||||
@@ -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<Color>),
|
||||
/// `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<R>(shown: &egui::scroll_area::ScrollAreaOutput<R>) -> 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<Pos2>,
|
||||
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 {
|
||||
/// Everything one editor needs that is not the text being edited.
|
||||
struct Editor<'a> {
|
||||
/// What the text is typed in: the font a text node is read in, or a
|
||||
/// monospaced one for a file, whose columns have to line up.
|
||||
font: egui::FontId,
|
||||
/// Stand-in shown while there is nothing to edit.
|
||||
hint: &'a str,
|
||||
/// A line above the text, for saying where the typing goes.
|
||||
note: Option<&'a str>,
|
||||
/// Whether to take keyboard focus this frame.
|
||||
focus: bool,
|
||||
/// How tall the card's contents came out, when that could be measured.
|
||||
content_height: Option<f32>,
|
||||
palette: &'a Palette,
|
||||
}
|
||||
|
||||
/// What one pass of the editor window produced.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct EditorPass {
|
||||
/// The text was typed in this frame.
|
||||
changed: bool,
|
||||
/// The card should be sized to fit its contents.
|
||||
fit: bool,
|
||||
/// The editor should close.
|
||||
close: bool,
|
||||
}
|
||||
|
||||
/// The text, filling the window and scrolling when it outgrows it, with the
|
||||
/// actions along the bottom.
|
||||
fn editor_contents(ui: &mut egui::Ui, buffer: &mut String, editor: Editor<'_>) -> EditorPass {
|
||||
let mut pass = EditorPass::default();
|
||||
|
||||
// The actions claim the bottom of the window before the text takes
|
||||
// everything that is left of it.
|
||||
egui::Panel::bottom("editor-actions").show(ui, |ui| {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
pass.fit = ui
|
||||
.add_enabled(
|
||||
editor.content_height.is_some(),
|
||||
Button::new("Fit card to text"),
|
||||
)
|
||||
.on_hover_text("Size the card to show all of the text, and no more")
|
||||
.on_disabled_hover_text("Zoom in far enough to see the card to measure it")
|
||||
.clicked();
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
pass.close = ui.button("Done").clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
});
|
||||
if let Some(note) = editor.note {
|
||||
egui::Panel::top("editor-note").show(ui, |ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(note)
|
||||
.size(12.0)
|
||||
.color(editor.palette.text_muted),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
// Rows enough to fill the window, so that the whole of it takes the
|
||||
// typing and not just the lines already written.
|
||||
let row = ui.fonts_mut(|f| f.row_height(&editor.font));
|
||||
let rows = ((ui.available_height() / row).floor() as usize).max(4);
|
||||
egui::ScrollArea::vertical()
|
||||
.scroll_source(ScrollSource::SCROLL_BAR)
|
||||
.vertical_scroll_offset(offset)
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user