Files
wordcraft/crates/engine/src/cmd/column.rs
T

279 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Column (block) selection: Alt+drag, or Ctrl+Shift+F8 then the arrow keys, selects the same
//! horizontal range on a run of lines. Copy and Cut take one line per block row, Delete /
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
//! Whole table cells that aren't one run of the text (part of a column, `CellRange`) are a block
//! of their paragraphs: edits and formatting go cell by cell; table commands take the cells.
//! Extending the selection from one cell into another (Shift+arrows, Shift+click, a drag) makes
//! such cells, as in Word (`table::extend_selection`).
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Block, Paragraph, Pos};
use super::{parse_pos, pos_json};
use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p};
/// Most lines a block covers (hostile params: a block over a huge document).
const MAX_LINES: usize = 100_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("select.column", "Column Selection", "Editing › Selection", select_column)
.key("Mod+Shift+F8")
.params(
r#"{"anchor": Pos, "focus": Pos} | {"from": {"page": n, "x": pt, "y": pt}, "to": {"page": n, "x": pt, "y": pt}, "story"?: StoryRef} | {"on"?: bool} (no corners: toggle column selection mode, where the arrow keys extend the block)"#,
)
.pure(),
]
}
/// Result: the corners, each block row's text and whether column mode is on.
fn result(s: &Session) -> CmdResult {
let rows: Vec<String> = s.column_segments().map(|segs| segs.iter().map(|(a, b)| row_text(s, a, b)).collect()).unwrap_or_default();
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "rows": rows, "columnMode": s.column_mode}))
}
fn row_text(s: &Session, a: &Pos, b: &Pos) -> String {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a.path != b.path || a.story != b.story {
return String::new();
}
s.doc.para_at(&a).and_then(|p| p.text.get(a.off.min(b.off)..b.off.max(a.off))).map(str::to_string).unwrap_or_default()
}
/// `{"page": n, "x": pt, "y": pt}`.
fn point(v: &Value, name: &str) -> Result<(usize, f32, f32), CmdError> {
match (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
(Some(pg), Some(x), Some(y)) => Ok((usize::try_from(pg).unwrap_or(usize::MAX), x, y)),
_ => Err(CmdError::Params(format!("`{name}` needs `page`, `x` and `y`"))),
}
}
fn select_column(s: &mut Session, v: &Value) -> CmdResult {
if let (Some(av), Some(fv)) = (v.get("anchor"), v.get("focus")) {
let a = parse_pos(av).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = parse_pos(fv).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
let (a, b) = (s.doc.clamp(&a), s.doc.clamp(&b));
if a.story != b.story {
return Err(CmdError::Params("`anchor` and `focus` must be in the same story".into()));
}
let l = s.layout();
let xa = l.caret_on(&a, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`anchor` isn't laid out".into()))?;
let xb = l.caret_on(&b, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`focus` isn't laid out".into()))?;
make(s, a, b, xa, xb);
return result(s);
}
if let (Some(fv), Some(tv)) = (v.get("from"), v.get("to")) {
let (fp, fx, fy) = point(fv, "from")?;
let (tp, tx, ty) = point(tv, "to")?;
let story = super::story_param(s, v);
let l = s.layout();
let a = l.hit(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?;
let b = l.hit(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?;
s.page_hint = tp;
make(s, a, b, fx, tx);
return result(s);
}
// Ctrl+Shift+F8: toggle the mode; the block starts at the caret.
let on = p::bool(v, "on").unwrap_or(!s.column_mode);
s.column_mode = on;
if on {
let f = s.sel.focus.clone();
let x = s.layout().caret_on(&f, s.page_hint).map(|c| c.x).unwrap_or(0.0);
make(s, f.clone(), f, x, x);
s.status = "Column selection: the arrow keys extend the block, Esc ends it".into();
} else {
s.status.clear();
}
result(s)
}
/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`.
fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 });
let (left, right) = (xa.min(xb), xa.max(xb));
let l = s.layout();
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
s.sel = Selection { anchor: a, focus: b };
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
s.pending = None;
}
/// Column mode: the caret moved to `to`; grow the block to it (keeping the goal column on
/// Up/Down).
fn extend_to(s: &mut Session, to: Pos) {
let Some(block) = s.column.clone() else { return };
let x = match s.goal_x {
Some(gx) => gx,
None => s.layout().caret_on(&to, s.page_hint).map(|c| c.x).unwrap_or(block.focus_x),
};
make(s, block.sel.anchor, to, block.anchor_x, x);
}
/// Run a command with the column block in mind (called by `Session::run` for every command).
pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Session, &Value) -> CmdResult, v: &Value) -> CmdResult {
// A block the selection has moved away from is gone.
if s.column.as_ref().is_some_and(|c| c.sel != s.sel) {
s.column = None;
s.column_mode = false;
}
// Placing the caret ends a block even where its corners are (a selected empty cell's are
// the same place).
if matches!(id, "caret.set" | "select.collapse") {
s.column = None;
}
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
let mut params = v.clone();
match params.as_object_mut() {
Some(o) => {
o.insert("extend".into(), Value::Bool(true));
}
None => params = json!({"extend": true}),
}
run(s, &params)?;
let to = s.sel.focus.clone();
extend_to(s, to);
return result(s);
}
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
s.column_mode = false;
}
// Extending the selection (Shift+arrows, Shift+click, a drag) goes on from where it really
// runs, then takes whole cells or rows the way Word does around tables.
if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) {
let shown = s.sel.clone();
let cells = s.cell_range().cloned();
s.sel = s.sel_ends().clone();
let ends = s.sel.clone();
if let Err(e) = run(s, v) {
s.sel = shown;
return Err(e);
}
let to = s.sel.focus.clone();
// Shift+Right out of a cell takes the cell alone first.
if !(matches!(id, "caret.right" | "caret.left") && super::table::select_cell_first(s, &ends, &to, cells.as_ref())) {
super::table::extend_selection(s, to);
}
return super::sel_result(s);
}
// Backspace over whole rows, whole columns or the whole table deletes them, as in Word
// (Delete only clears them, as for any other cells).
if id == "text.backspace"
&& let Some(r) = super::table::backspace_cells(s)
{
return r;
}
// Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each,
// as in Word (#2802).
if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward")
&& let Some(r) = super::table::clear_cells(s)
{
return r;
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
let cells = s.cell_range().is_some();
match id {
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
// from it without clearing it, as in Word.
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
delete(s, &segs)?;
Ok(r)
}
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
delete(s, &segs)?;
super::sel_result(s)
}
"format.changeCase" | "format.clear" | "format.resetChar" => per_row(s, run, v, &segs),
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
// Pasting formatting (the format painter) leaves the text alone.
_ if mutates && (id.starts_with("text.") || (id.starts_with("edit.paste") && id != "edit.pasteFormat")) => {
delete(s, &segs)?;
run(s, v)
}
_ if mutates => {
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
s.sel = Selection::caret(at);
s.column = None;
run(s, v)
}
_ => run(s, v),
}
}
/// Copy: one paragraph per block row (formatting kept); the plain text joins rows with newlines.
fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
let mut blocks = Vec::new();
let mut parts = std::collections::BTreeMap::new();
let mut rows = Vec::new();
for (a, b) in segs {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
let frag = if a.path == b.path && a.story == b.story && a != b { s.doc.copy_range(&a, &b) } else { Fragment::default() };
// Text boxes in a row keep their stories (keyed by this document's part ids).
parts.extend(frag.parts);
let mut para = frag
.blocks
.into_iter()
.find_map(|bl| match bl {
Block::Para(p) => Some(p),
Block::Table(_) => None,
})
.unwrap_or_else(|| Paragraph::with_text("", CharProps::default()));
para.section = None;
rows.push(row_text(s, &a, &b));
blocks.push(Block::Para(para));
}
s.clipboard_text = rows.join("\n");
s.clipboard = Some(Fragment { blocks, parts });
Ok(super::edit::copy_result(s))
}
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
/// the block's top-left corner.
fn delete(s: &mut Session, segs: &[(Pos, Pos)]) -> Result<(), CmdError> {
for (a, b) in segs.iter().rev() {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a == b || a.path != b.path || a.story != b.story {
continue;
}
if s.doc.settings.track_changes {
super::track_delete(s, &a, &b)?;
} else {
s.doc.delete_range(&a, &b)?;
}
}
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
s.sel = Selection::caret(at);
s.column = None;
s.column_mode = false;
s.goal_x = None;
s.pending = None;
Ok(())
}
/// Run a selection command once per non-empty row, then restore the block.
fn per_row(s: &mut Session, run: fn(&mut Session, &Value) -> CmdResult, v: &Value, segs: &[(Pos, Pos)]) -> CmdResult {
let block = s.column.take();
for (a, b) in segs.iter().rev() {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a == b || a.path != b.path {
continue;
}
s.sel = Selection { anchor: a, focus: b };
if let Err(e) = run(s, v) {
s.column = block;
return Err(e);
}
}
if let Some(c) = &block {
s.sel = c.sel.clone();
}
s.column = block;
result(s)
}