From 4f1e03a1a48f7cc371e7ab509738a642a4e25db6 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 13 Nov 2026 15:01:25 -0700 Subject: [PATCH 1/2] feat(ui-egui): Insert Space drags room into the page (#164) Insert > Insert Space put one blank paragraph after the caret. It is now OneNote's tool: with it in hand, the page shows a guide at the pointer's height and a vertical-resize pointer, and a drag moves every box and every ink stroke that starts below where the drag began, down by as far as it went. A drag up takes room away, never past the top of the page. The band of room is shown while dragging. One drag, then the tool is put down; Esc puts it down without a change. The boxes move the way a dragged box does, so the page keeps the new arrangement and Undo takes the room back out. The ink moves with them (Pad::shift_below). The tool sits in a foreground area of its own, so the drag does not reach the words, the boxes or the pen underneath. ui.inspect reports whether it is in hand. The blank-paragraph action is kept for the code that still uses it, but the ribbon button is the tool. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y4im4nvZvQHZdShop1ff8w --- crates/ui-egui/src/app.rs | 97 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/inkpad.rs | 17 +++++++ crates/ui-egui/src/ribbon.rs | 9 +++- crates/ui-egui/tests/ui.rs | 81 ++++++++++++++++++++++++++++++ docs/gaps.md | 7 ++- i18n/ar-XB.tsv | 2 + i18n/en-XA.tsv | 2 + i18n/en.tsv | 2 + 8 files changed, 214 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c9d12c01..59795b09 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -279,6 +279,51 @@ fn shown(p: &Path) -> String { notecraft_store::tree::display_path(p) } +/// The Insert Space tool's own layer over the visible page (issue #164): a vertical-resize +/// pointer, a guide at the pointer's height, and while it is dragged, the band of room being +/// added (below the start) or taken away (above it). Returns where the drag began and how far it +/// went, in screen points, on the frame it is let go. +fn insert_space_overlay(ctx: &Context, visible: egui::Rect, t: &Tokens) -> Option<(f32, f32)> { + let id = Id::new("insert-space-tool"); + let from_id = id.with("from"); + let mut done = None; + egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(visible.min).constrain(false).movable(false).show(ctx, |ui| { + let (rect, resp) = ui.allocate_exact_size(visible.size(), Sense::drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::tl_id!("ui.insert_space_how"))); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + if resp.drag_started() + && let Some(p) = resp.interact_pointer_pos() + { + ui.data_mut(|d| d.insert_temp(from_id, p.y)); + } + let from = ui.data(|d| d.get_temp::(from_id)); + let now = ui.ctx().pointer_latest_pos().filter(|p| rect.contains(*p) || resp.dragged()).map(|p| p.y.clamp(rect.min.y, rect.max.y)); + let painter = ui.painter(); + let line = egui::Stroke::new(1.5, t.accent); + match (from, now) { + (Some(a), Some(b)) => { + let band = egui::Rect::from_x_y_ranges(rect.x_range(), egui::Rangef::new(a.min(b), a.max(b))); + painter.rect_filled(band, 0.0, t.accent.gamma_multiply(0.12)); + painter.hline(rect.x_range(), a, line); + painter.hline(rect.x_range(), b, egui::Stroke::new(1.0, t.accent.gamma_multiply(0.7))); + } + (None, Some(y)) => { + painter.hline(rect.x_range(), y, line); + } + _ => {} + } + if resp.drag_stopped() { + if let (Some(a), Some(p)) = (from, resp.interact_pointer_pos()) { + done = Some((a, p.y.clamp(rect.min.y, rect.max.y) - a)); + } + ui.data_mut(|d| d.remove::(from_id)); + } + }); + done +} + fn notebook_for(path: &Path) -> Option { let mut current = path; loop { @@ -820,6 +865,8 @@ pub struct NoteCraftApp { canvas_drag: Option, /// The page's sideways scroll bar and its thumb, when one was drawn last frame (issue #188). page_hbar: Option<(egui::Rect, egui::Rect)>, + /// True while Insert Space is in hand: the next drag on the page adds or takes away room. + space_tool: bool, /// What the pointer does on the page (the Draw tab). pointer: ribbon::Pointer, /// The tools on the rack, as the owner has arranged them, and which is in hand. Both @@ -981,6 +1028,7 @@ impl NoteCraftApp { page_rect: None, canvas_pan: egui::Vec2::ZERO, page_hbar: None, + space_tool: false, canvas_drag: None, pointer: ribbon::Pointer::Write, rack, @@ -3354,6 +3402,7 @@ impl NoteCraftApp { rack: &self.rack, nib: self.nib, in_table: self.caret_in_table(), + space_tool: self.space_tool, table_borders: self.caret_table_has_borders(), gridcraft: self.gridcraft.is_some(), page_colour: self.editor.as_ref().and_then(|e| e.colour), @@ -3774,6 +3823,17 @@ impl NoteCraftApp { } ribbon::Act::PageWidth => self.fit_page_width = true, ribbon::Act::FormatPainter(sticky) => self.pick_up_formatting(sticky), + ribbon::Act::InsertSpaceTool => { + if self.editor.is_none() { + self.say(crate::tl_id!("ui.open_a_page_first")); + return; + } + self.space_tool = !self.space_tool; + if self.space_tool { + self.pointer = ribbon::Pointer::Write; + self.say(crate::tl_id!("ui.insert_space_how")); + } + } ribbon::Act::NotBuilt(what) => { self.status = crate::i18n::format_message_id("ui.name_is_not_built_yet_see_the_roadmap_for_what_is_coming", &[("name", crate::tl!(what))]); @@ -6165,6 +6225,9 @@ impl NoteCraftApp { let mut hbar = crate::hscroll::Shown::default(); // What the page's right-click menu asked for, run once the page's borrow is over. let mut page_acts: Vec = Vec::new(); + // Insert Space: in hand this frame, and whether it was used or put down. + let space_on = self.space_tool && self.pointer == ribbon::Pointer::Write; + let mut space_over = false; { let Some(ed) = self.editor.as_mut() else { return }; let missing_fonts = crate::fonts::ensure_page_fonts(ui.ctx(), &crate::fonts::page_families(&ed.blocks)); @@ -6348,6 +6411,35 @@ impl NoteCraftApp { } page_span = Some(span); } + // Insert Space (issue #164): a drag down the page pushes everything that + // starts below where it began, boxes and ink together, down by as far as + // it went; a drag up pulls it back up. One drag, then the tool is put down. + if space_on { + let visible = ui.clip_rect().intersect(viewport.with_max_y(viewport.max.y.max(ui.clip_rect().max.y))); + if let Some((from, by)) = insert_space_overlay(ui.ctx(), visible, &tokens) { + let z = zoom_scale.max(0.01); + let (at, by) = ((from - moved.origin.y) / z, by / z); + let below: Vec = (0..ed.boxes.len()).filter(|i| ed.boxes.get(*i).is_some_and(|b| b.y >= at)).collect(); + // Room taken away never pulls anything above the top of the page. + let highest = below.iter().filter_map(|i| ed.boxes.get(*i)).map(|b| b.y).fold(f32::INFINITY, f32::min); + let by = if by < 0.0 && highest.is_finite() { by.max(-highest) } else { by }; + for i in &below { + if let Some(b) = ed.boxes.get_mut(*i) { + *b = notecraft_model::container::Container::new(b.x, b.y + by, b.width, b.blocks); + } + } + if !below.is_empty() && by != 0.0 { + moved.boxes = true; + } + if let Some(pad) = pad.as_mut() { + ink_changed |= pad.shift_below(at, by); + } + space_over = true; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + space_over = true; + } + } // The selection box takes ink as well as boxes, as in OneNote, and what // it took moves together: boxes dragged take the ink with them, ink // dragged takes the boxes. @@ -6607,6 +6699,9 @@ impl NoteCraftApp { self.canvas_pan.x = (self.canvas_pan.x + hbar.pan).clamp(-1.0e6, 1.0e6); ui.ctx().request_repaint(); } + if space_over { + self.space_tool = false; + } for act in page_acts { self.ribbon_act(act); } @@ -8065,6 +8160,8 @@ impl NoteCraftApp { // [left, top, right, bottom] in screen points, or null when everything fits. "horizontalBar": self.page_hbar.map(|(track, _)| [track.min.x, track.min.y, track.max.x, track.max.y]), "horizontalThumb": self.page_hbar.map(|(_, thumb)| [thumb.min.x, thumb.min.y, thumb.max.x, thumb.max.y]), + // True while Insert Space is in hand (issue #164). + "insertSpace": self.space_tool, }, // How the side panel is arranged: one tree, or the organisers and the pages in // columns of their own, with the first one hidden or not. diff --git a/crates/ui-egui/src/inkpad.rs b/crates/ui-egui/src/inkpad.rs index ce75e4d7..fb1af0d7 100644 --- a/crates/ui-egui/src/inkpad.rs +++ b/crates/ui-egui/src/inkpad.rs @@ -362,6 +362,23 @@ impl Pad { (shift != (0.0, 0.0)).then_some(shift) } + /// Insert Space (issue #164): every stroke whose top is at or below `y`, in page points, + /// moves down by `dy`, or up when `dy` is negative. True when any moved. + pub fn shift_below(&mut self, y: f32, dy: f32) -> bool { + if dy == 0.0 || !dy.is_finite() { + return false; + } + let below: Vec = + (0..self.drawing.strokes.len()).filter(|i| pick::bounds(&self.drawing, &[*i]).is_some_and(|(_, top, _, _)| top >= y)).collect(); + if below.is_empty() { + return false; + } + pick::move_by(&mut self.drawing, &below, 0.0, dy); + self.dirty = true; + self.touched = true; + true + } + /// The box round all the ink on the page, in page points. pub fn bounds(&self) -> Option<[f32; 4]> { let all: Vec = (0..self.drawing.strokes.len()).collect(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index bdd78b08..ce0d0bb7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -101,6 +101,8 @@ pub enum Act { ClearFormatting, /// Put a tag on the line the caret is in, or take it off again. Tag(Tag), + /// Take up the Insert Space tool, or put it down (issue #164). + InsertSpaceTool, /// Something OneNote has that NoteCraft does not: say so plainly. NotBuilt(&'static str), /// Change the shape of the table the caret is in. @@ -419,6 +421,8 @@ pub struct View<'a> { /// The tools on the rack, and which of them is in hand. pub rack: &'a [Nib], pub nib: usize, + /// True while the Insert Space tool is in hand. + pub space_tool: bool, /// True while the caret is in a table, which is when the Table tab is offered. pub in_table: bool, /// Whether the table the caret is in is drawn with lines round its cells. @@ -1584,8 +1588,9 @@ fn mark_button(ui: &mut Ui, mark: Mark, label: &str, tip: &str, on: bool, t: &To /// work is rather than a short list of what happens to exist. fn insert_tab(ui: &mut Ui, view: &View<'_>, t: &Tokens, acts: &mut Vec) { group(ui, crate::tl_id!("ui.insert"), t, |ui| { - if big(ui, Icon::InsertSpace, crate::tl_id!("ui.insert_space_2"), crate::tl_id!("ui.add_a_blank_paragraph_at_the_caret"), t).clicked() { - acts.push(Act::Insert("space")); + // OneNote's tool: a drag on the page adds room there, or takes it away (issue #164). + if big_on(ui, Icon::InsertSpace, crate::tl_id!("ui.insert_space_2"), crate::tl_id!("ui.insert_space_tip"), view.space_tool, t).clicked() { + acts.push(Act::InsertSpaceTool); } }); group(ui, crate::tl_id!("ui.tables"), t, |ui| { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index d29622a4..4cd34dff 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -9314,3 +9314,84 @@ fn content_wider_than_the_view_gets_a_horizontal_scroll_bar() { assert_eq!(state.pointer("/ink/strokes").and_then(Value::as_u64), Some(0), "the bar took the press, not the pen: {state}"); } } + +/// Issue #164: Insert > Insert Space is OneNote's tool. A drag down the page pushes the boxes +/// and the ink below where it began down by as far as it went; a drag up takes room away; Esc +/// puts the tool down; Undo puts the boxes back. +#[test] +fn insert_space_drags_room_into_the_page_and_out_again() { + let (_t, mut app, s) = sample_app("ui-insert-space"); + let page = s.md_page.to_string_lossy().to_string(); + app.open_path(&page); + let mut h = sized_harness(app, LIGHT, egui::vec2(1400.0, 1000.0)); + settle(&mut h); + let blocks = ask(&mut h, "ui.inspect", json!({})).expect("inspect").pointer("/page/blocks").and_then(Value::as_u64).unwrap_or(0); + assert!(blocks > 3, "the sample page has blocks to split: {blocks}"); + ask( + &mut h, + "engine.execute", + json!({"command": "page.containers", "params": {"path": page.clone(), "boxes": format!("40,20,320,3;40,500,320,{}", blocks - 3)}}), + ) + .expect("two boxes"); + ask(&mut h, "ui.open", json!({"path": page.clone()})).expect("open"); + settle(&mut h); + let boxes = |h: &mut Harness<'_, NoteCraftApp>| { + let v = ask(h, "ui.inspect", json!({})).expect("inspect"); + let y = |i: usize| v.pointer(&format!("/page/containers/{i}/y")).and_then(Value::as_f64).unwrap_or(-1.0); + (y(0), y(1)) + }; + let tool = + |h: &mut Harness<'_, NoteCraftApp>| ask(h, "ui.inspect", json!({})).expect("inspect").pointer("/canvas/insertSpace").and_then(Value::as_bool); + assert_eq!(boxes(&mut h), (20.0, 500.0)); + let origin = page_origin(&mut h); + + // Ink under the line moves with the boxes; the pen is put down again before the tool. + h.get_by_label("Draw").click(); + settle(&mut h); + h.get_by_label("Pen red").click(); + settle(&mut h); + drag_by(&mut h, origin + egui::vec2(500.0, 420.0), egui::vec2(80.0, 0.0)); + let ink_top = |h: &mut Harness<'_, NoteCraftApp>| { + ask(h, "ui.inspect", json!({})).expect("inspect").pointer("/ink/bounds/1").and_then(Value::as_f64).expect("ink bounds") + }; + let ink_before = ink_top(&mut h); + h.get_by_label("Insert").click(); + settle(&mut h); + assert_eq!(tool(&mut h), Some(false)); + h.get_by_label("Insert Space").click(); + settle(&mut h); + assert_eq!(tool(&mut h), Some(true), "the tool is in hand"); + assert_eq!(ask(&mut h, "ui.inspect", json!({})).expect("inspect").pointer("/ink/pointer").and_then(Value::as_str), Some("write")); + + // Down 100 points from between the boxes: the lower box and the ink move, the upper box stays. + drag_by(&mut h, origin + egui::vec2(600.0, 300.0), egui::vec2(0.0, 100.0)); + assert_eq!(boxes(&mut h), (20.0, 600.0), "room was added above the lower box"); + assert!((ink_top(&mut h) - (ink_before + 100.0)).abs() < 1.0, "the ink below moved with it"); + assert_eq!(tool(&mut h), Some(false), "one drag, then the tool is put down"); + review(&mut h, "insert_space_added"); + let kept = ask(&mut h, "engine.execute", json!({"command": "page.containers", "params": {"path": page.clone()}})).expect("layout"); + assert!(kept.as_str().unwrap_or_default().contains("40,600,320"), "the page keeps the new place: {kept}"); + + // Undo puts the box back where it was. + ask(&mut h, "ui.undo", json!({})).expect("undo"); + settle(&mut h); + assert_eq!(boxes(&mut h).1, 500.0, "undo takes the room back out"); + ask(&mut h, "ui.redo", json!({})).expect("redo"); + settle(&mut h); + assert_eq!(boxes(&mut h).1, 600.0); + + // Up 150 points from just above the lower box: the room is taken away again. + h.get_by_label("Insert Space").click(); + settle(&mut h); + drag_by(&mut h, origin + egui::vec2(600.0, 590.0), egui::vec2(0.0, -150.0)); + assert_eq!(boxes(&mut h), (20.0, 450.0), "room was taken away"); + + // Esc puts the tool down without changing anything. + h.get_by_label("Insert Space").click(); + settle(&mut h); + assert_eq!(tool(&mut h), Some(true)); + ask(&mut h, "ui.key", json!({"key": "Escape"})).expect("Escape"); + settle(&mut h); + assert_eq!(tool(&mut h), Some(false), "Esc puts the tool down"); + assert_eq!(boxes(&mut h), (20.0, 450.0)); +} diff --git a/docs/gaps.md b/docs/gaps.md index d3a6641b..e6d9089f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -444,7 +444,12 @@ Playground hosts with the Files API continue using their existing persistence pa picture, a link, the three time stamps, an equation, a symbol picker, insert space, and NoteCraft's own Mermaid diagram. File printout, screen clipping, online video, transcription, recording, page templates and stickers remain unbuilt. Spreadsheet is - available through the existing GridCraft integration when installed. + available through the existing GridCraft integration when installed. Insert Space is + OneNote's tool: with it in hand, a drag down the page moves every box and every stroke of + ink that starts below where the drag began down by as far as it went, and a drag up moves + them up, never past the top of the page; a guide shows the line and the band of room, Esc + puts the tool down, and one drag is one step of Undo. It moves boxes, as OneNote moves its + containers: it does not open a gap between two lines inside one box. - **Unread and author history** are not built. Page version history stores snapshots of the page's main file; changes to attached files and drawings are not captured by those snapshots. (Tags are: to-do boxes and the other familiar marks, diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index eb88141a..e53defee 100644 --- a/i18n/ar-XB.tsv +++ b/i18n/ar-XB.tsv @@ -836,3 +836,5 @@ @id ui.session_only_storage_notice [[RLI]]$$$Session-only notes: changes disappear when you reload or close this tab.@@@[[PDI]] @id ui.session_storage_guard_failed [[RLI]]$$$Could not enable a tab-close warning. Copy your notes before reloading or closing this tab.@@@[[PDI]] @id ui.scroll_the_page_sideways [[RLI]]$$$Scroll the page sideways@@@[[PDI]] +@id ui.insert_space_tip [[RLI]]$$$Drag down the page to add room, or up to take it away@@@[[PDI]] +@id ui.insert_space_how [[RLI]]$$$Drag down where you want more room, or up to take room away. Esc puts the tool down.@@@[[PDI]] diff --git a/i18n/en-XA.tsv b/i18n/en-XA.tsv index d69eb98e..e1d8f014 100644 --- a/i18n/en-XA.tsv +++ b/i18n/en-XA.tsv @@ -836,3 +836,5 @@ @id ui.session_only_storage_notice $$$Session-only notes: changes disappear when you reload or close this tab.@@@ @id ui.session_storage_guard_failed $$$Could not enable a tab-close warning. Copy your notes before reloading or closing this tab.@@@ @id ui.scroll_the_page_sideways $$$Scroll the page sideways@@@ +@id ui.insert_space_tip $$$Drag down the page to add room, or up to take it away@@@ +@id ui.insert_space_how $$$Drag down where you want more room, or up to take room away. Esc puts the tool down.@@@ diff --git a/i18n/en.tsv b/i18n/en.tsv index bb28a803..7cb2c3c6 100644 --- a/i18n/en.tsv +++ b/i18n/en.tsv @@ -836,3 +836,5 @@ @id ui.session_only_storage_notice Session-only notes: changes disappear when you reload or close this tab. @id ui.session_storage_guard_failed Could not enable a tab-close warning. Copy your notes before reloading or closing this tab. @id ui.scroll_the_page_sideways Scroll the page sideways +@id ui.insert_space_tip Drag down the page to add room, or up to take it away +@id ui.insert_space_how Drag down where you want more room, or up to take room away. Esc puts the tool down. From 6d8e388a38f78377b31dc05653682e713306db1b Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 13 Nov 2026 22:31:05 -0700 Subject: [PATCH 2/2] Fix indentation of the InsertSpaceTool arm closing brace (cargo fmt) --- crates/ui-egui/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 1fca1436..98677a77 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -3911,7 +3911,7 @@ impl NoteCraftApp { self.pointer = ribbon::Pointer::Write; self.say(crate::tl_id!("ui.insert_space_how")); } - } + } ribbon::Act::FindTags => { // OneNote looks in the section first; with no page open there is no section. let scope = self.tags_pane.as_ref().map_or(if self.editor.is_some() { TagScope::Section } else { TagScope::All }, |p| p.scope);