mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 03:47:09 -06:00
279 lines
12 KiB
Rust
279 lines
12 KiB
Rust
//! 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, ¶ms)?;
|
||
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)
|
||
}
|