Edit titles in their boxes, with vim keys inside the text
Titles are typed right in their boxes on the canvas: the box grows with the text, the terminal's cursor sits in it (a bar while typing, a block on the letter with vim keys), and the border turns the insert color. `i`, `A` and Enter edit at the end, `I` at the start, `cc` and `S` type the title anew. The document follows the text as it's typed, so the layout stays current, and the whole edit is one undo step. Esc gives vim's normal mode inside the text (TextEditor, in ui/): h l w b e 0 ^ $, f t F T with ; and ,, the d c y operators with any of those motions or the iw aw i" a" i( a( text objects, x X s S D C Y, r ~, p P, counts, u and Ctrl-r for the edit's own changes, and `.` for the last one, typing included. j k gg G and J work on lines, for bodies. It follows vim where it's quirky too: cw acts like ce, dw stops at the end of the line, Esc steps back onto the last letter. Keys have two new tables, `text` and `insert`, and in text an operator takes motions as targets (`dw`, `c$`, `2d3fx`), which which-key lists. Rapid entry: after `o`, `O` or `a`, the new idea's title is typed at once; Enter starts the next idea, Tab puts the one being typed under the idea above, Shift-Tab takes it out a level, and Enter on an empty idea stops. An idea left empty goes away without a trace in the undo history (a group whose edits cancel out leaves no step); a title left empty comes back as it was. Inside a title, `o` and `O` start a new sibling. Registers are shared by the whole app, so text yanked in one title pastes into another; `"a`-`"z` and `"_` work, and `"+` stays inside cumulus until the app core connects the clipboard. The mouse while editing: a click in the text moves the text cursor, a click anywhere else keeps the text and finishes the edit, and a double-click on a title starts editing at that letter. `>>` and `<<` now move nodes: under the node above, as its last child, or out a level, right after the old parent, with counts taking in the next siblings. The command line's input became TextInput, line-aware so the editor can use it for bodies too; bodies themselves get edited in the Note view. The example's tests type every key above into the app and check the text and the cursor after each one against what vim does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6eedbe0ce2
commit
84aefbecdb
31 files changed
+3289
-341
No files matched your search
@@ -7,11 +7,13 @@
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId};
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
use crate::ui::text_editor::layout;
|
||||
|
||||
/// The width titles wrap at, unless the config says otherwise.
|
||||
pub const DEFAULT_MAX_WIDTH: u16 = 30;
|
||||
@@ -51,6 +53,33 @@ impl NodeBox {
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of a node whose title is being edited.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DraftBox {
|
||||
/// The box, showing the text as typed.
|
||||
pub node_box: NodeBox,
|
||||
/// The rows the text wraps into (see [`layout::rows`]).
|
||||
pub rows: Vec<Range<usize>>,
|
||||
/// The cursor's row and column in the text.
|
||||
pub cursor: (usize, usize),
|
||||
}
|
||||
|
||||
/// Lays out the box of a node whose title is being edited: the text as
|
||||
/// typed (trailing spaces too), wrapped the way the editor wraps it, with
|
||||
/// room for the cursor after the last character.
|
||||
pub fn draft_box(node: &Node, text: &str, cursor: usize, badge: String, max_width: u16) -> DraftBox {
|
||||
let max_width = usize::from(max_width.max(4));
|
||||
let rows = layout::rows(text, max_width);
|
||||
let cursor = layout::cell(text, &rows, cursor);
|
||||
let lines = rows.iter().map(|row| text[row.clone()].trim_end_matches('\n').to_string()).collect();
|
||||
let tags = node.tags.to_string();
|
||||
let text_width = (0..rows.len()).map(|index| layout::row_width(text, &rows, index)).max().unwrap_or(0);
|
||||
let inner = text_width.max(cursor.1).max(footer_width(&tags, &badge).saturating_sub(2)).min(max_width);
|
||||
let size = CanvasSize::new(clamp_u16(inner + 4), clamp_u16(rows.len() + 2));
|
||||
let node_box = NodeBox { rect: CanvasRect::new(node.pos, size), lines, tags, badge };
|
||||
DraftBox { node_box, rows, cursor }
|
||||
}
|
||||
|
||||
/// The boxes of every node in a document at Normal zoom, without badges:
|
||||
/// enough to tell where nodes are and how much room they take.
|
||||
pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap<NodeId, CanvasRect> {
|
||||
|
||||
@@ -40,8 +40,14 @@ pub fn first_child_spot(
|
||||
/// Where a new node just after `sibling` goes: right below it, then further
|
||||
/// down until it's clear.
|
||||
pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
||||
let Some(rect) = rects.get(&sibling) else { return CanvasPos::default() };
|
||||
free_spot(CanvasPos::new(rect.left(), rect.bottom() + ROW_GAP), size, rects)
|
||||
let Some(&rect) = rects.get(&sibling) else { return CanvasPos::default() };
|
||||
spot_below(rect, size, rects)
|
||||
}
|
||||
|
||||
/// Where a box goes below another box: right below it, then further down
|
||||
/// until it's clear. The box above doesn't need to be in `rects`.
|
||||
pub fn spot_below(above: CanvasRect, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
||||
free_spot(CanvasPos::new(above.left(), above.bottom() + ROW_GAP), size, rects)
|
||||
}
|
||||
|
||||
/// The first spot at `start` or below it where a box of `size` keeps clear of
|
||||
|
||||
+11
-32
@@ -170,9 +170,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::model::{CanvasPos, Fold, Label, Link};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::view::ViewSettings;
|
||||
use crate::view::canvas::{Camera, Zoom};
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
||||
/// look at the canvas without a terminal:
|
||||
@@ -181,16 +180,10 @@ mod tests {
|
||||
#[ignore = "prints the canvas; run it by name"]
|
||||
fn show_canvas() {
|
||||
let doc = crate::storage::document_file::parse(include_str!("../../../examples/garden.cum")).unwrap();
|
||||
let setup = Setup::default();
|
||||
for zoom in [Zoom::Normal, Zoom::Far, Zoom::Overview] {
|
||||
let camera = Camera { origin: CanvasPos::new(-1, 0), zoom };
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
doc.root(),
|
||||
camera,
|
||||
Size::new(84, 36),
|
||||
&Theme::default(),
|
||||
&ViewSettings::default(),
|
||||
);
|
||||
let scene = Scene::build(&setup.ctx(&doc, doc.root()), camera, Size::new(84, 36));
|
||||
println!("── {} {}", zoom.name(), "─".repeat(70));
|
||||
for row in snapshot(&scene) {
|
||||
println!("{row}");
|
||||
@@ -211,15 +204,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_garden_at_normal_zoom() {
|
||||
let (doc, g) = garden();
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
g.beds,
|
||||
Camera::default(),
|
||||
Size::new(52, 17),
|
||||
&Theme::default(),
|
||||
&ViewSettings::default(),
|
||||
);
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let scene = Scene::build(&setup.ctx(&doc, g.beds), Camera::default(), Size::new(52, 17));
|
||||
assert_eq!(
|
||||
snapshot(&scene),
|
||||
vec![
|
||||
@@ -250,9 +236,9 @@ mod tests {
|
||||
let (mut doc, g) = garden();
|
||||
let orange = crate::model::ColorName::parse("orange").unwrap();
|
||||
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
|
||||
let theme = Theme::default();
|
||||
let scene =
|
||||
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, &ViewSettings::default());
|
||||
let setup = Setup::default();
|
||||
let theme = &setup.theme;
|
||||
let scene = Scene::build(&setup.ctx(&doc, g.soil), Camera::default(), Size::new(52, 8));
|
||||
let area = Rect::new(0, 0, 52, 8);
|
||||
let mut buf = Buffer::empty(area);
|
||||
scene.render(area, &mut buf);
|
||||
@@ -272,14 +258,8 @@ mod tests {
|
||||
e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16))
|
||||
})
|
||||
.unwrap();
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
g.root,
|
||||
Camera::default(),
|
||||
Size::new(52, 19),
|
||||
&Theme::default(),
|
||||
&ViewSettings::default(),
|
||||
);
|
||||
let setup = Setup::default();
|
||||
let scene = Scene::build(&setup.ctx(&doc, g.root), Camera::default(), Size::new(52, 19));
|
||||
let text = snapshot(&scene);
|
||||
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
|
||||
assert!(text.iter().any(|row| row.contains("feeds")), "the link's label: {text:#?}");
|
||||
@@ -288,8 +268,7 @@ mod tests {
|
||||
// Far zoom: one line per node, half the height. The link climbs from
|
||||
// the compost note up into Watering, labeled where it runs level.
|
||||
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
||||
let scene =
|
||||
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), &ViewSettings::default());
|
||||
let scene = Scene::build(&setup.ctx(&doc, g.root), far, Size::new(40, 9));
|
||||
assert_eq!(
|
||||
snapshot(&scene),
|
||||
vec![
|
||||
|
||||
+78
-16
@@ -3,19 +3,23 @@
|
||||
//! the same scene, so what you click is what you see.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::style::Color;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::boxes::{NodeBox, truncate};
|
||||
use super::boxes::{NodeBox, draft_box, truncate};
|
||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||
use super::lines::{LineLayer, LineOwner, Paint};
|
||||
use super::route::{Bend, route};
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::text_edit::TextEdit;
|
||||
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::ui::theme::{LineStyle, Theme};
|
||||
use crate::view::ViewSettings;
|
||||
use crate::view::ViewContext;
|
||||
|
||||
/// How many columns of a title show at Far zoom.
|
||||
const FAR_TITLE_WIDTH: usize = 14;
|
||||
@@ -53,6 +57,16 @@ pub(super) struct Mark {
|
||||
pub(super) color: Color,
|
||||
}
|
||||
|
||||
/// The title being edited, as drawn: where the text cursor is, and how the
|
||||
/// text sits in its box, for clicks.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Editing {
|
||||
rect: ScreenRect,
|
||||
text: String,
|
||||
rows: Vec<Range<usize>>,
|
||||
cursor: ScreenPos,
|
||||
}
|
||||
|
||||
/// Everything the canvas draws in one frame.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Scene {
|
||||
@@ -61,6 +75,9 @@ pub struct Scene {
|
||||
pub(super) glyphs: Glyphs,
|
||||
pub(super) accent: Color,
|
||||
pub(super) muted: Color,
|
||||
/// The border of the cursor's box: the accent, or the typing color while
|
||||
/// its title is typed.
|
||||
cursor_color: Color,
|
||||
/// The nodes in drawing order: the cursor's last, on top.
|
||||
pub(super) nodes: Vec<SceneNode>,
|
||||
/// Where each node is in `nodes`.
|
||||
@@ -70,19 +87,20 @@ pub struct Scene {
|
||||
covered: Vec<Option<usize>>,
|
||||
pub(super) lines: LineLayer,
|
||||
pub(super) marks: Vec<Mark>,
|
||||
editing: Option<Editing>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Works out the frame: which nodes show (folding, the cursor), where
|
||||
/// they are on screen, and every line between them.
|
||||
pub fn build(
|
||||
doc: &Document,
|
||||
cursor: NodeId,
|
||||
camera: Camera,
|
||||
size: Size,
|
||||
theme: &Theme,
|
||||
settings: &ViewSettings,
|
||||
) -> Scene {
|
||||
/// they are on screen, and every line between them. A title being edited
|
||||
/// shows as typed, with the text cursor in it.
|
||||
pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene {
|
||||
let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings);
|
||||
let edit = match ctx.mode {
|
||||
Mode::Text(edit) => Some(edit),
|
||||
_ => None,
|
||||
};
|
||||
let typing = edit.is_some_and(|edit| edit.editor.is_typing());
|
||||
let visible = Visible::new(doc, cursor, settings.focus);
|
||||
let mut scene = Scene {
|
||||
size,
|
||||
@@ -90,6 +108,7 @@ impl Scene {
|
||||
glyphs: theme.glyphs,
|
||||
accent: theme.accent,
|
||||
muted: theme.muted,
|
||||
cursor_color: if typing { theme.insert } else { theme.accent },
|
||||
covered: vec![None; size.area() as usize],
|
||||
lines: LineLayer::new(size),
|
||||
..Scene::default()
|
||||
@@ -98,8 +117,11 @@ impl Scene {
|
||||
let Some(node) = doc.node(id) else { continue };
|
||||
let style = theme.node_style(node, id == doc.root());
|
||||
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
||||
let (rect, shape) =
|
||||
shape(node, badge, style.border, camera, theme.glyphs, settings.max_node_width);
|
||||
let max_width = settings.max_node_width;
|
||||
let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) {
|
||||
Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width),
|
||||
None => shape(node, badge, style.border, camera, theme.glyphs, max_width),
|
||||
};
|
||||
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
|
||||
}
|
||||
// The cursor's node goes last, on top of any it overlaps.
|
||||
@@ -139,6 +161,45 @@ impl Scene {
|
||||
self.lines.owner(at)
|
||||
}
|
||||
|
||||
/// The cell the text cursor is on, while a title is edited.
|
||||
pub fn text_cursor(&self) -> Option<ScreenPos> {
|
||||
self.editing.as_ref().map(|editing| editing.cursor)
|
||||
}
|
||||
|
||||
/// Where a click lands in the title being edited, if it's in its box.
|
||||
pub fn text_offset_at(&self, at: ScreenPos) -> Option<usize> {
|
||||
let editing = self.editing.as_ref().filter(|editing| editing.rect.contains(at))?;
|
||||
let row = usize::try_from(at.row - editing.rect.top() - 1).unwrap_or(0);
|
||||
let column = usize::try_from(at.col - editing.rect.left() - 2).unwrap_or(0);
|
||||
Some(layout::offset(&editing.text, &editing.rows, row, column))
|
||||
}
|
||||
|
||||
/// The box of the node whose title is being edited, showing the text as
|
||||
/// typed. Remembers where the text and its cursor are.
|
||||
fn draft(
|
||||
&mut self,
|
||||
node: &Node,
|
||||
edit: &TextEdit,
|
||||
badge: String,
|
||||
border: Border,
|
||||
camera: Camera,
|
||||
max_width: u16,
|
||||
) -> (ScreenRect, Shape) {
|
||||
let text = edit.editor.text();
|
||||
let draft = draft_box(node, text, edit.editor.cursor(), badge, max_width);
|
||||
let at = camera.to_screen(node.pos);
|
||||
let size = draft.node_box.rect.size;
|
||||
let rect = ScreenRect::new(at.col, at.row, i32::from(size.width), i32::from(size.height));
|
||||
let (row, column) = draft.cursor;
|
||||
let cursor = ScreenPos::new(
|
||||
rect.left() + 2 + i32::try_from(column).unwrap_or(0),
|
||||
rect.top() + 1 + i32::try_from(row).unwrap_or(0),
|
||||
);
|
||||
self.editing = Some(Editing { rect, text: text.to_string(), rows: draft.rows, cursor });
|
||||
let NodeBox { lines, tags, badge, .. } = draft.node_box;
|
||||
(rect, Shape::Box { lines, tags, badge, border })
|
||||
}
|
||||
|
||||
/// The index (in `nodes`) of the topmost node on a cell.
|
||||
pub(super) fn covered_at(&self, at: ScreenPos) -> Option<usize> {
|
||||
covered_at(&self.covered, self.size, at)
|
||||
@@ -158,13 +219,14 @@ impl Scene {
|
||||
}
|
||||
|
||||
/// Adds the boxes' borders to the line layer, so lines joining them get
|
||||
/// the right characters. The cursor's box is thick, in the accent color.
|
||||
/// the right characters. The cursor's box is thick, in the accent color
|
||||
/// (the typing color while its title is typed).
|
||||
fn add_borders(&mut self) {
|
||||
let Scene { nodes, covered, lines, size, accent, .. } = self;
|
||||
let Scene { nodes, covered, lines, size, cursor_color, .. } = self;
|
||||
for (index, node) in nodes.iter().enumerate() {
|
||||
let Shape::Box { border, .. } = &node.shape else { continue };
|
||||
let (border, color) =
|
||||
if node.selected { (Border::Thick, *accent) } else { (*border, node.color) };
|
||||
if node.selected { (Border::Thick, *cursor_color) } else { (*border, node.color) };
|
||||
let paint = Paint { color, owner: LineOwner::Border(node.id) };
|
||||
lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index));
|
||||
}
|
||||
|
||||
+88
-23
@@ -3,18 +3,20 @@
|
||||
//! it wants to the document goes out as an action.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
|
||||
use super::camera::{Camera, ScreenPos, Zoom};
|
||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||
use super::navigate::nearest;
|
||||
use super::scene::Scene;
|
||||
use crate::app::action::{Action, AppAction, Message};
|
||||
use crate::app::mode::{Grab, Mode};
|
||||
use crate::app::text_edit::{EditAt, StartEdit};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Command, Motion};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::view::{View, ViewContext, ViewKind};
|
||||
|
||||
/// How close (in cells) the cursor's node may come to an edge before the
|
||||
@@ -65,7 +67,37 @@ impl CanvasView {
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &ViewContext<'_>) -> Scene {
|
||||
Scene::build(ctx.doc, ctx.cursor, self.camera, self.area.as_size(), ctx.theme, ctx.settings)
|
||||
Scene::build(ctx, self.camera, self.area.as_size())
|
||||
}
|
||||
|
||||
/// A left press: on a node, selects it and starts dragging its branch;
|
||||
/// on empty space, starts panning.
|
||||
fn press(&mut self, at: ScreenPos) -> Vec<Action> {
|
||||
match self.scene.node_at(at) {
|
||||
Some(id) => {
|
||||
let moved = CanvasOffset::default();
|
||||
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
|
||||
vec![Action::cursor(id)]
|
||||
}
|
||||
None => {
|
||||
self.drag = Some(Drag::Pan { last: at });
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a double-click on a node's title lands in it, to start editing
|
||||
/// there (at the end when zoomed out, where titles are cut short).
|
||||
fn title_spot(&self, id: NodeId, at: ScreenPos, ctx: &ViewContext<'_>) -> EditAt {
|
||||
let (Some(rect), Some(node)) = (self.scene.rect_of(id), ctx.doc.node(id)) else { return EditAt::End };
|
||||
if self.camera.zoom != Zoom::Normal {
|
||||
return EditAt::End;
|
||||
}
|
||||
let title = node.title().as_str();
|
||||
let rows = layout::rows(title, usize::from(ctx.settings.max_node_width.max(4)));
|
||||
let row = usize::try_from(at.row - rect.top() - 1).unwrap_or(0);
|
||||
let column = usize::try_from(at.col - rect.left() - 2).unwrap_or(0);
|
||||
EditAt::Offset(layout::offset(title, &rows, row, column))
|
||||
}
|
||||
|
||||
/// hjkl: the nearest node on show that way, `times` times over. Folds are
|
||||
@@ -182,6 +214,8 @@ impl View for CanvasView {
|
||||
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||
match mode {
|
||||
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
|
||||
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Mode::Text(_) => &[Context::Text],
|
||||
Mode::Move(_) => &[Context::Move],
|
||||
// The command line reads its own keys.
|
||||
Mode::Command(_) => &[],
|
||||
@@ -190,25 +224,46 @@ impl View for CanvasView {
|
||||
|
||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||
self.area = area;
|
||||
// A title is edited in its box: zoomed out, there's no box.
|
||||
if matches!(ctx.mode, Mode::Text(_)) && self.camera.zoom != Zoom::Normal {
|
||||
self.zoom(Zoom::Normal, ctx);
|
||||
}
|
||||
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
|
||||
// While a node is dragged, the view stays put under the mouse.
|
||||
let follow = std::mem::take(&mut self.reveal) || (moved && self.drag.is_none());
|
||||
self.scene = self.build(ctx);
|
||||
if follow
|
||||
&& let Some(rect) = self.scene.rect_of(ctx.cursor)
|
||||
&& self.camera.reveal(rect, area.as_size(), SCROLL_OFF)
|
||||
{
|
||||
let mut panned = follow
|
||||
&& self
|
||||
.scene
|
||||
.rect_of(ctx.cursor)
|
||||
.is_some_and(|rect| self.camera.reveal(rect, area.as_size(), SCROLL_OFF));
|
||||
// While typing, the text cursor stays on screen as the box grows.
|
||||
if let Some(cell) = self.scene.text_cursor() {
|
||||
let rect = ScreenRect::new(cell.col, cell.row, 1, 1);
|
||||
panned |= self.camera.reveal(rect, area.as_size(), 0);
|
||||
}
|
||||
if panned {
|
||||
self.scene = self.build(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
||||
frame.render_widget(&self.scene, self.area);
|
||||
if let Some(cell) = self.scene.text_cursor()
|
||||
&& let (Ok(col), Ok(row)) = (u16::try_from(cell.col), u16::try_from(cell.row))
|
||||
&& col < self.area.width
|
||||
&& row < self.area.height
|
||||
{
|
||||
frame.set_cursor_position(Position::new(self.area.x + col, self.area.y + row));
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
if let Mode::Move(grab) = ctx.mode {
|
||||
return self.move_command(typed, *grab, ctx);
|
||||
match ctx.mode {
|
||||
Mode::Move(grab) => return self.move_command(typed, *grab, ctx),
|
||||
// Keys inside a title are the edit's, not the view's.
|
||||
Mode::Text(_) => return None,
|
||||
Mode::Normal | Mode::Command(_) => {}
|
||||
}
|
||||
let steps = cells(typed);
|
||||
let half_page = i32::from(self.area.height / 2);
|
||||
@@ -229,21 +284,34 @@ impl View for CanvasView {
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
fn mouse(&mut self, gesture: Gesture, _ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let at = self.screen(at);
|
||||
match self.scene.node_at(at) {
|
||||
Some(id) => {
|
||||
let moved = CanvasOffset::default();
|
||||
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
|
||||
vec![Action::cursor(id)]
|
||||
}
|
||||
None => {
|
||||
self.drag = Some(Drag::Pan { last: at });
|
||||
Vec::new()
|
||||
}
|
||||
let Mode::Text(edit) = ctx.mode else { return self.press(at) };
|
||||
// While a title is edited, a click in it moves the text
|
||||
// cursor; a click anywhere else keeps the edit, finishes it,
|
||||
// and does what a click does.
|
||||
if let Some(offset) = self.scene.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let mut actions = edit.finish(ctx);
|
||||
actions.extend(self.press(at));
|
||||
actions
|
||||
}
|
||||
Gesture::DoublePress { at } => {
|
||||
let at = self.screen(at);
|
||||
if let Some(offset) = self.scene.text_offset_at(at) {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let Some(id) = self.scene.node_at(at) else { return Vec::new() };
|
||||
let mut actions = match ctx.mode {
|
||||
Mode::Text(edit) => edit.finish(ctx),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let start = StartEdit { at: self.title_spot(id, at, ctx), new_node: None };
|
||||
actions.extend([Action::cursor(id), Action::App(AppAction::StartEdit(start))]);
|
||||
actions
|
||||
}
|
||||
Gesture::Drag { at, .. } => self.drag_to(self.screen(at)),
|
||||
Gesture::Release { .. } => {
|
||||
@@ -260,9 +328,6 @@ impl View for CanvasView {
|
||||
self.camera.pan(cols, rows);
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::DoublePress { .. } => {
|
||||
vec![Action::say(Message::info("editing titles comes in stage 6"))]
|
||||
}
|
||||
Gesture::RightPress { .. } => {
|
||||
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user