Rework Scrolling and Zooming
This commit is contained in:
@@ -29,8 +29,8 @@ reshuffle.
|
|||||||
| Gesture | Result |
|
| Gesture | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Double-click empty canvas | New text note, ready to type in |
|
| Double-click empty canvas | New text note, ready to type in |
|
||||||
| Double-click a note | Edit its Markdown in place |
|
| Double-click a note | Edit its Markdown in an editor window |
|
||||||
| Double-click a file/link node | Open it 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 | 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 |
|
||||||
@@ -39,8 +39,7 @@ reshuffle.
|
|||||||
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
| Drag on empty canvas | Rubber-band selection (hold <kbd>Shift</kbd> to add) |
|
||||||
| Middle- or right-drag | Pan |
|
| Middle- or right-drag | Pan |
|
||||||
| Drop files on the window | A file node each; a `.canvas` file opens instead |
|
| 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 |
|
| Wheel | Zoom around the pointer, anywhere on the canvas |
|
||||||
| <kbd>Ctrl</kbd>+wheel | Zoom around the pointer |
|
|
||||||
|
|
||||||
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
|
**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and
|
||||||
fenced code, quotes, bullet, numbered and task lists, rules, images and links.
|
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
|
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
|
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
|
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
|
layer, so real widgets (file previews, links) keep working while the canvas is
|
||||||
the canvas is scaled.
|
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 |
|
| Module | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+1
-1
@@ -1134,7 +1134,7 @@ const SHORTCUTS: &[(&str, &str)] = &[
|
|||||||
("Arrow keys", "Nudge (hold Shift for 10 units)"),
|
("Arrow keys", "Nudge (hold Shift for 10 units)"),
|
||||||
("Double-click", "New note, or edit what is under the cursor"),
|
("Double-click", "New note, or edit what is under the cursor"),
|
||||||
("Middle / right drag", "Pan the canvas"),
|
("Middle / right drag", "Pan the canvas"),
|
||||||
("Ctrl+wheel", "Zoom"),
|
("Wheel", "Zoom around the pointer"),
|
||||||
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),
|
("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 [
|
for tip in [
|
||||||
"Double-click the canvas to add a note",
|
"Double-click the canvas to add a note",
|
||||||
"Drag a dot on a node's edge to connect it",
|
"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",
|
"Hold Shift to add to the selection",
|
||||||
] {
|
] {
|
||||||
ui.label(
|
ui.label(
|
||||||
|
|||||||
+418
-215
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
|||||||
use eframe::egui::{
|
use eframe::egui::{
|
||||||
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
|
self, Align, Atom, Button, Color32, CornerRadius, CursorIcon, Id, LayerId, Layout,
|
||||||
PointerButton, Pos2, Rect, RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder,
|
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;
|
use eframe::emath::TSTransform;
|
||||||
|
|
||||||
@@ -40,6 +40,9 @@ const EDGE_HIT_RADIUS: f32 = 8.0;
|
|||||||
const HANDLE_SIZE: f32 = 9.0;
|
const HANDLE_SIZE: f32 = 9.0;
|
||||||
const NODE_PADDING: f32 = 10.0;
|
const NODE_PADDING: f32 = 10.0;
|
||||||
const NODE_CORNER: f32 = 6.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
|
// Selection
|
||||||
@@ -258,7 +261,7 @@ pub struct CanvasView {
|
|||||||
to_global: TSTransform,
|
to_global: TSTransform,
|
||||||
pub selection: Selection,
|
pub selection: Selection,
|
||||||
gesture: Gesture,
|
gesture: Gesture,
|
||||||
/// Node whose text is being edited in place.
|
/// Node the editor is open on.
|
||||||
editing: Option<String>,
|
editing: Option<String>,
|
||||||
/// The text an open file-node editor is working on, while one is open.
|
/// The text an open file-node editor is working on, while one is open.
|
||||||
file_edit: Option<FileEdit>,
|
file_edit: Option<FileEdit>,
|
||||||
@@ -271,8 +274,11 @@ 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 far each node's contents are scrolled, and how far they can go.
|
/// How tall the contents of the node being edited came out this frame, so
|
||||||
body_scroll: HashMap<String, BodyScroll>,
|
/// 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
|
/// 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.
|
||||||
@@ -301,15 +307,6 @@ struct FileEdit {
|
|||||||
buffer: String,
|
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
|
/// 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.
|
/// has been laid out, its size and position on screen are still a guess.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -340,7 +337,7 @@ impl CanvasView {
|
|||||||
connect_target: None,
|
connect_target: None,
|
||||||
snap_to_grid: false,
|
snap_to_grid: false,
|
||||||
files: FileStore::default(),
|
files: FileStore::default(),
|
||||||
body_scroll: HashMap::new(),
|
body_height: None,
|
||||||
menu_at: None,
|
menu_at: None,
|
||||||
viewport: None,
|
viewport: None,
|
||||||
pending_fit: None,
|
pending_fit: None,
|
||||||
@@ -358,14 +355,14 @@ impl CanvasView {
|
|||||||
self.file_edit = None;
|
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) {
|
pub fn edit_node(&mut self, id: &str) {
|
||||||
self.editing = Some(id.to_owned());
|
self.editing = Some(id.to_owned());
|
||||||
self.focus_editor = true;
|
self.focus_editor = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts in-place editing of the file behind a file node, reporting
|
/// Opens the editor on the file behind a file node, reporting `false`
|
||||||
/// `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 {
|
||||||
let can = self.can_edit_file(doc, id);
|
let can = self.can_edit_file(doc, id);
|
||||||
if can {
|
if can {
|
||||||
@@ -638,13 +635,8 @@ 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);
|
||||||
// Deleted nodes leave a scroll position behind. The map only ever holds
|
// Measured again below, by drawing the card the editor is open on.
|
||||||
// nodes that have been on screen, so it outgrows the canvas only once
|
self.body_height = None;
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
if self
|
if self
|
||||||
.editing
|
.editing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -726,16 +718,15 @@ impl CanvasView {
|
|||||||
let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas());
|
let at = self.menu_at.unwrap_or_else(|| self.centre_in_canvas());
|
||||||
background.context_menu(|ui| background_menu(ui, at, &mut output));
|
background.context_menu(|ui| background_menu(ui, at, &mut output));
|
||||||
|
|
||||||
// Last: which node the wheel belongs to is only known once they have
|
// Last: the editor offers to fit the card to its contents, and how
|
||||||
// all been drawn.
|
// tall those are is only known once the card has been drawn.
|
||||||
self.scroll_hovered_body(&scene);
|
self.show_edit_window(ui.ctx(), doc, palette);
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- input ------------------------------------------------------------
|
// -- input ------------------------------------------------------------
|
||||||
|
|
||||||
/// Panning is a drag; the wheel is left to the nodes (see
|
/// Panning is a drag; the wheel zooms, bare or with a modifier held.
|
||||||
/// [`Self::scroll_hovered_body`]), and only zooms with the modifier held.
|
|
||||||
fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) {
|
fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) {
|
||||||
if background.dragged_by(PointerButton::Middle)
|
if background.dragged_by(PointerButton::Middle)
|
||||||
|| background.dragged_by(PointerButton::Secondary)
|
|| background.dragged_by(PointerButton::Secondary)
|
||||||
@@ -750,7 +741,13 @@ impl CanvasView {
|
|||||||
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
|
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
|
||||||
return;
|
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 {
|
if zoom_delta != 1.0 {
|
||||||
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
|
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
|
||||||
/ self.to_global.scaling;
|
/ 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 ---------------------------------------------------------
|
// -- painting ---------------------------------------------------------
|
||||||
|
|
||||||
fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) {
|
fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) {
|
||||||
@@ -922,9 +889,6 @@ impl CanvasView {
|
|||||||
if scale >= 0.18 && !is_group {
|
if scale >= 0.18 && !is_group {
|
||||||
let inner = rect.shrink(NODE_PADDING);
|
let inner = rect.shrink(NODE_PADDING);
|
||||||
if inner.width() > 4.0 && inner.height() > 4.0 {
|
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(
|
let mut body = scene.new_child(
|
||||||
UiBuilder::new()
|
UiBuilder::new()
|
||||||
.id_salt(("body", &id))
|
.id_salt(("body", &id))
|
||||||
@@ -938,16 +902,20 @@ impl CanvasView {
|
|||||||
files: &mut self.files,
|
files: &mut self.files,
|
||||||
scale,
|
scale,
|
||||||
};
|
};
|
||||||
// Contents taller than the node scroll instead of being
|
// Contents taller than the node are cut off by the clip
|
||||||
// cut off.
|
// rectangle: the canvas shows what a card holds, never
|
||||||
let scroll = self.body_scroll.get(&id).copied().unwrap_or_default();
|
// less. The editor is where the rest of it is read.
|
||||||
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| {
|
if let Some(target) =
|
||||||
nodes::render_body(ui, &doc.canvas.nodes[index], &mut ctx)
|
nodes::render_body(&mut body, &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);
|
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
|
/// A card crops what it shows, so editing on the card cropped the writing
|
||||||
/// file that is not plain text — so that the caller draws its contents as
|
/// with it. The window is sized for the text instead: it scrolls, it can
|
||||||
/// usual instead.
|
/// be as large as the screen, and being non-modal it leaves the canvas
|
||||||
fn show_editor(
|
/// live behind it — the card can be dragged and resized while it is open,
|
||||||
&mut self,
|
/// and it shows every keystroke as it lands.
|
||||||
scene: &mut egui::Ui,
|
///
|
||||||
doc: &mut Document,
|
/// A group's label is still edited on the canvas: one line fits there.
|
||||||
index: usize,
|
fn show_edit_window(&mut self, ctx: &egui::Context, doc: &mut Document, palette: &Palette) {
|
||||||
inner: Rect,
|
let Some(id) = self.editing.clone() else {
|
||||||
palette: &Palette,
|
self.file_edit = None;
|
||||||
) -> bool {
|
return;
|
||||||
match doc.canvas.nodes[index].kind {
|
};
|
||||||
NodeKind::Text { .. } => {
|
let Some(index) = doc.canvas.nodes.iter().position(|node| node.id == id) else {
|
||||||
self.edit_text(scene, doc, index, inner, palette);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
NodeKind::File { .. } => self.edit_file(scene, doc, index, inner, palette),
|
|
||||||
_ => {
|
|
||||||
self.stop_editing();
|
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(
|
fn edit_text(
|
||||||
&mut self,
|
&mut self,
|
||||||
scene: &mut egui::Ui,
|
ctx: &egui::Context,
|
||||||
doc: &mut Document,
|
doc: &mut Document,
|
||||||
index: usize,
|
index: usize,
|
||||||
inner: Rect,
|
|
||||||
palette: &Palette,
|
palette: &Palette,
|
||||||
) {
|
) {
|
||||||
let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else {
|
let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut buffer = text.clone();
|
let mut buffer = text.clone();
|
||||||
let id = doc.canvas.nodes[index].id.clone();
|
let fits = self.body_height;
|
||||||
let mut body = scene.new_child(UiBuilder::new().id_salt(("editor", &id)).max_rect(inner));
|
let focus = std::mem::take(&mut self.focus_editor);
|
||||||
body.set_clip_rect(inner.intersect(scene.clip_rect()));
|
let at = focus.then(|| self.editor_pos(&doc.canvas.nodes[index]));
|
||||||
// Text longer than the node scrolls, the same as it does when read, and
|
let editor = Editor {
|
||||||
// from where reading left off.
|
font: egui::FontId::proportional(14.0),
|
||||||
let scroll = self.body_scroll.get(&id).copied().unwrap_or_default();
|
hint: "Markdown\u{2026}",
|
||||||
let shown = body_scroll_area(scroll.offset).show(&mut body, |ui| {
|
note: None,
|
||||||
ui.add(
|
focus,
|
||||||
TextEdit::multiline(&mut buffer)
|
content_height: fits,
|
||||||
.frame(egui::Frame::NONE)
|
palette,
|
||||||
.desired_width(ui.available_width())
|
};
|
||||||
.desired_rows(1)
|
let (open, pass) = editor_window(ctx, "Edit text", at, |ui| {
|
||||||
.hint_text(RichText::new("Markdown\u{2026}").color(palette.text_muted))
|
editor_contents(ui, &mut buffer, editor)
|
||||||
.font(egui::FontId::proportional(14.0)),
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
self.body_scroll.insert(id.clone(), measure(&shown));
|
|
||||||
let response = shown.inner;
|
if pass.changed {
|
||||||
if self.focus_editor {
|
|
||||||
response.request_focus();
|
|
||||||
self.focus_editor = false;
|
|
||||||
}
|
|
||||||
if response.changed() {
|
|
||||||
doc.begin_change("Edit text");
|
doc.begin_change("Edit text");
|
||||||
if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind {
|
if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind {
|
||||||
*text = buffer;
|
*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();
|
self.stop_editing();
|
||||||
doc.end_change();
|
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
|
/// 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
|
/// typing in a node never touches the disk on its own. A file this editor
|
||||||
/// a file this editor will not change; see [`CanvasView::can_edit_file`].
|
/// will not change closes it again; see [`CanvasView::can_edit_file`].
|
||||||
fn edit_file(
|
fn edit_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
scene: &mut egui::Ui,
|
ctx: &egui::Context,
|
||||||
doc: &mut Document,
|
doc: &mut Document,
|
||||||
index: usize,
|
index: usize,
|
||||||
inner: Rect,
|
|
||||||
palette: &Palette,
|
palette: &Palette,
|
||||||
) -> bool {
|
) {
|
||||||
let node = &doc.canvas.nodes[index];
|
let node = &doc.canvas.nodes[index];
|
||||||
let NodeKind::File { file, subpath } = &node.kind else {
|
let NodeKind::File { file, subpath } = &node.kind else {
|
||||||
return false;
|
return;
|
||||||
};
|
};
|
||||||
let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone());
|
let (id, file, subpath) = (node.id.clone(), file.clone(), subpath.clone());
|
||||||
let path = nodes::resolve_path(doc.base_dir().as_deref(), &file);
|
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 {
|
let Some((section, buffer)) = self.readable_part(&path, subpath.as_deref()) else {
|
||||||
self.stop_editing();
|
self.stop_editing();
|
||||||
return false;
|
return;
|
||||||
};
|
};
|
||||||
self.file_edit = Some(FileEdit {
|
self.file_edit = Some(FileEdit {
|
||||||
id: id.clone(),
|
id,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
section,
|
section,
|
||||||
buffer,
|
buffer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let Self {
|
let title = match &subpath {
|
||||||
editing,
|
Some(subpath) => format!("Edit {} {subpath}", crate::model::file_name(&file)),
|
||||||
file_edit,
|
None => format!("Edit {}", crate::model::file_name(&file)),
|
||||||
focus_editor,
|
};
|
||||||
body_scroll,
|
// Said plainly, because this is the one editor whose typing lands
|
||||||
files,
|
// somewhere other than the canvas.
|
||||||
..
|
let note = format!("Writes to {file}, and is written out when the canvas is saved");
|
||||||
} = self;
|
let fits = self.body_height;
|
||||||
let Some(edit) = file_edit.as_mut() else {
|
let focus = std::mem::take(&mut self.focus_editor);
|
||||||
return false;
|
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(
|
// The buffer and the store are both borrowed out of `self` here, so
|
||||||
UiBuilder::new()
|
// everything that needs the view whole waits until after this block.
|
||||||
.id_salt(("editor", &id))
|
let (open, pass) = {
|
||||||
.max_rect(inner)
|
let Self {
|
||||||
.layout(Layout::top_down(Align::Min)),
|
file_edit, files, ..
|
||||||
);
|
} = self;
|
||||||
body.set_clip_rect(inner.intersect(scene.clip_rect()));
|
let Some(edit) = file_edit.as_mut() else {
|
||||||
// A header, so that it is clear the typing goes into the file on disk
|
return;
|
||||||
// and not into the canvas.
|
};
|
||||||
body.horizontal(|ui| {
|
let (open, pass) = editor_window(ctx, &title, at, |ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 4.0;
|
editor_contents(ui, &mut edit.buffer, editor)
|
||||||
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);
|
if pass.changed {
|
||||||
|
|
||||||
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() {
|
match edit.section.clone() {
|
||||||
// A node showing one section writes that section back into the
|
// A node showing one section writes that section back into
|
||||||
// file around it, leaving the rest of it alone.
|
// the file around it, leaving the rest of it alone.
|
||||||
Some(range) => {
|
Some(range) => {
|
||||||
if let Ok(loaded) = files.entry(&edit.path) {
|
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);
|
edit.section = Some(moved);
|
||||||
files.set(&edit.path, text);
|
files.set(&edit.path, text);
|
||||||
}
|
}
|
||||||
@@ -1291,11 +1237,60 @@ impl CanvasView {
|
|||||||
None => files.set(&edit.path, edit.buffer.clone()),
|
None => files.set(&edit.path, edit.buffer.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if response.lost_focus() {
|
(open, pass)
|
||||||
*editing = None;
|
};
|
||||||
*file_edit = None;
|
|
||||||
|
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
|
/// The text an editor on `path` starts from: the whole file, or the lines
|
||||||
@@ -1357,6 +1352,16 @@ impl CanvasView {
|
|||||||
StrokeKind::Outside,
|
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`
|
/// Resize and connect handles for the current selection. Returns `true`
|
||||||
@@ -1591,9 +1596,6 @@ impl CanvasView {
|
|||||||
} else if !self.selection.has_node(id) {
|
} else if !self.selection.has_node(id) {
|
||||||
self.selection.set_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 {
|
if let Some(id) = &interactions.double_clicked {
|
||||||
@@ -1610,7 +1612,6 @@ impl CanvasView {
|
|||||||
} else if !self.selection.has_node(id) {
|
} else if !self.selection.has_node(id) {
|
||||||
self.selection.set_node(id);
|
self.selection.set_node(id);
|
||||||
}
|
}
|
||||||
self.stop_editing();
|
|
||||||
let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone());
|
let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone());
|
||||||
let origin = ids
|
let origin = ids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1654,7 +1655,6 @@ impl CanvasView {
|
|||||||
if !shift {
|
if !shift {
|
||||||
self.selection.clear();
|
self.selection.clear();
|
||||||
}
|
}
|
||||||
self.stop_editing();
|
|
||||||
self.gesture = Gesture::Band { start, add: shift };
|
self.gesture = Gesture::Band { start, add: shift };
|
||||||
}
|
}
|
||||||
if let Gesture::Band { start, add } = self.gesture {
|
if let Gesture::Band { start, add } = self.gesture {
|
||||||
@@ -1707,7 +1707,6 @@ impl CanvasView {
|
|||||||
if !shift {
|
if !shift {
|
||||||
self.selection.clear();
|
self.selection.clear();
|
||||||
}
|
}
|
||||||
self.stop_editing();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1927,7 +1926,7 @@ enum Pass {
|
|||||||
/// been drawn, because all three commands rearrange the node list.
|
/// been drawn, because all three commands rearrange the node list.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
enum NodeAction {
|
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,
|
Edit,
|
||||||
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.
|
||||||
@@ -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
|
/// The window the editors live in: outside the canvas layer, so that it is
|
||||||
/// it can show scrolls rather than swallowing the rest, positioned at `offset`.
|
/// never cropped or scaled by the view, and non-modal, so that the card behind
|
||||||
///
|
/// it stays live. Reports whether it is still open.
|
||||||
/// Only the bar scrolls it by itself. The wheel is routed by
|
fn editor_window(
|
||||||
/// [`CanvasView::scroll_hovered_body`] instead: a scroll area cannot see the
|
ctx: &egui::Context,
|
||||||
/// wheel from in here, because it asks `Context::rect_contains_pointer`, which
|
title: &str,
|
||||||
/// only answers for a layer egui knows as an area — and the canvas layer is a
|
at: Option<Pos2>,
|
||||||
/// bare sublayer.
|
contents: impl FnOnce(&mut egui::Ui) -> EditorPass,
|
||||||
/// Dragging is left out too, so that a drag inside a node moves the node and a
|
) -> (bool, EditorPass) {
|
||||||
/// touch drag on its text does not scroll instead.
|
let mut open = true;
|
||||||
/// Where a scroll area ended up: the offset it settled on (the bar may have
|
let mut window = egui::Window::new(title)
|
||||||
/// been dragged, or the text cursor may have pulled it along) and how far it
|
// A fixed id, so that the window keeps its size and place when the
|
||||||
/// could still go.
|
// editor moves to another node and the title changes with it.
|
||||||
fn measure<R>(shown: &egui::scroll_area::ScrollAreaOutput<R>) -> BodyScroll {
|
.id(Id::new("node-editor"))
|
||||||
let max = (shown.content_size.y - shown.inner_rect.height()).max(0.0);
|
.open(&mut open)
|
||||||
BodyScroll {
|
.collapsible(false)
|
||||||
offset: shown.state.offset.y.clamp(0.0, max),
|
.resizable(true)
|
||||||
max,
|
.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()
|
egui::ScrollArea::vertical()
|
||||||
.scroll_source(ScrollSource::SCROLL_BAR)
|
|
||||||
.vertical_scroll_offset(offset)
|
|
||||||
.auto_shrink([false, false])
|
.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`.
|
/// The side of `rect` closest to `point`.
|
||||||
@@ -2436,8 +2527,74 @@ mod tests {
|
|||||||
(dir, note, doc)
|
(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]
|
#[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 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 (dir, note, mut doc) = canvas_with_a_file_node(None);
|
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]
|
#[test]
|
||||||
fn a_click_in_the_inspector_stays_in_the_inspector() {
|
fn a_click_in_the_inspector_stays_in_the_inspector() {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user