mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
Dialog enum/name/title/body: both Manage Styles and New/Modify Table Style kept (NewStyle keeps #270's back field); module doc and Styles pane buttons unioned; Serbian rows for the Manage Styles strings; parity regenerated (387/431) and floor raised to 89.7%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** |
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **431** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **387 / 431 (90%)** |
|
||||
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
|
||||
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
|
||||
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |
|
||||
@@ -47,7 +47,7 @@ the icon art).
|
||||
|
||||
| Area | Status | Parity |
|
||||
|---|---|---|
|
||||
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat | 85% |
|
||||
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat, column (block) selection | 85% |
|
||||
| Character & paragraph formatting | Nearly all properties; Font/Paragraph dialogs | 85% |
|
||||
| Styles (gallery, pane, create/modify, style sets, themes) | Good | 75% |
|
||||
| Lists (bullets, numbering, multilevel, restart, set value) | Good | 75% |
|
||||
|
||||
@@ -52,27 +52,52 @@ pub struct TableStyleParts {
|
||||
pub cell_margins: Option<[f32; 4]>,
|
||||
pub header_fill: Option<Rgb>,
|
||||
pub header_chr: CharProps,
|
||||
/// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the
|
||||
/// border between header rows, `inside_v` between its cells.
|
||||
pub header_borders: Option<Borders>,
|
||||
pub band_fill: Option<Rgb>,
|
||||
/// Character formatting of the odd row bands (`band1Horz` region).
|
||||
pub band_chr: CharProps,
|
||||
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
|
||||
pub band_borders: Option<Borders>,
|
||||
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
|
||||
pub band_size: Option<u32>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
/// Shading and cell borders of the first column (`firstCol` region).
|
||||
pub first_col_fill: Option<Rgb>,
|
||||
pub first_col_borders: Option<Borders>,
|
||||
/// The last column (`lastCol` region).
|
||||
pub last_col_fill: Option<Rgb>,
|
||||
pub last_col_chr: CharProps,
|
||||
pub last_col_borders: Option<Borders>,
|
||||
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
|
||||
/// unset falls back to `total_border_top`.
|
||||
pub total_fill: Option<Rgb>,
|
||||
pub total_borders: Option<Borders>,
|
||||
/// The odd column bands (`band1Vert` region).
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
/// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins,
|
||||
/// the rest is inherited; borders merge edge by edge.
|
||||
pub fn overlay(&mut self, patch: &TableStyleParts) {
|
||||
self.borders = match (self.borders, patch.borders) {
|
||||
(Some(b), Some(p)) => Some(Borders {
|
||||
top: p.top.or(b.top),
|
||||
left: p.left.or(b.left),
|
||||
bottom: p.bottom.or(b.bottom),
|
||||
right: p.right.or(b.right),
|
||||
between: p.between.or(b.between),
|
||||
inside_v: p.inside_v.or(b.inside_v),
|
||||
}),
|
||||
let merge = |b: Option<Borders>, p: Option<Borders>| match (b, p) {
|
||||
(Some(mut b), Some(p)) => {
|
||||
b.overlay(&p);
|
||||
Some(b)
|
||||
}
|
||||
(b, p) => p.or(b),
|
||||
};
|
||||
self.borders = merge(self.borders, patch.borders);
|
||||
self.header_borders = merge(self.header_borders, patch.header_borders);
|
||||
self.band_borders = merge(self.band_borders, patch.band_borders);
|
||||
self.band_size = patch.band_size.or(self.band_size);
|
||||
self.band_chr.overlay(&patch.band_chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.cell_margins = patch.cell_margins.or(self.cell_margins);
|
||||
self.header_fill = patch.header_fill.or(self.header_fill);
|
||||
@@ -81,6 +106,16 @@ impl TableStyleParts {
|
||||
self.first_col_chr.overlay(&patch.first_col_chr);
|
||||
self.total_chr.overlay(&patch.total_chr);
|
||||
self.total_border_top = patch.total_border_top.or(self.total_border_top);
|
||||
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
|
||||
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
|
||||
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
|
||||
self.last_col_chr.overlay(&patch.last_col_chr);
|
||||
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
|
||||
self.total_fill = patch.total_fill.or(self.total_fill);
|
||||
self.total_borders = merge(self.total_borders, patch.total_borders);
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -444,9 +444,11 @@ impl Reader<'_> {
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(props::shd_fill);
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr") {
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill);
|
||||
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
|
||||
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
|
||||
match c.attr("w:type") {
|
||||
Some("wholeTable") => {
|
||||
@@ -464,12 +466,35 @@ impl Reader<'_> {
|
||||
Some("firstRow") => {
|
||||
t.header_fill = fill;
|
||||
t.header_chr = chr;
|
||||
t.header_borders = cell_borders;
|
||||
}
|
||||
Some("band1Horz") => {
|
||||
t.band_fill = fill;
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
}
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("band1Horz") => t.band_fill = fill,
|
||||
Some("firstCol") => t.first_col_chr = chr,
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) {
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(t) = &st.table {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
|
||||
w.open("w:tblPr", &[]);
|
||||
if let Some(n) = t.band_size {
|
||||
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
}
|
||||
@@ -632,35 +635,49 @@ fn style_xml(w: &mut W, st: &Style) {
|
||||
}
|
||||
w.close("w:tblPr");
|
||||
}
|
||||
let cond =
|
||||
|w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option<wordcraft_doc::Rgb>, top: Option<&wordcraft_doc::props::Border>| {
|
||||
if !props::has_rpr(chr) && fill.is_none() && top.is_none() {
|
||||
return;
|
||||
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
|
||||
// and shading.
|
||||
let cond = |w: &mut W,
|
||||
ty: &str,
|
||||
chr: &wordcraft_doc::CharProps,
|
||||
fill: Option<wordcraft_doc::Rgb>,
|
||||
borders: Option<wordcraft_doc::props::Borders>| {
|
||||
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
|
||||
return;
|
||||
}
|
||||
w.open("w:tblStylePr", &[("w:type", ty)]);
|
||||
if props::has_rpr(chr) {
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(w, chr);
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if fill.is_some() || borders.is_some() {
|
||||
w.open("w:tcPr", &[]);
|
||||
if let Some(bs) = &borders {
|
||||
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
|
||||
}
|
||||
w.open("w:tblStylePr", &[("w:type", ty)]);
|
||||
if props::has_rpr(chr) {
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(w, chr);
|
||||
w.close("w:rPr");
|
||||
if let Some(f) = fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
|
||||
}
|
||||
if fill.is_some() || top.is_some() {
|
||||
w.open("w:tcPr", &[]);
|
||||
if let Some(b) = top {
|
||||
let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() };
|
||||
props::borders(w, "w:tcBorders", &bs, None, &[]);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
w.close("w:tblStylePr");
|
||||
};
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
w.close("w:tblStylePr");
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, None);
|
||||
cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref());
|
||||
cond(w, "firstCol", &t.first_col_chr, None, None);
|
||||
cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
|
||||
// The total row's borders, its top edge falling back to the built-in top rule.
|
||||
let total_borders = match (t.total_borders, t.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
|
||||
};
|
||||
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
|
||||
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
|
||||
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
|
||||
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
|
||||
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
|
||||
}
|
||||
w.close("w:style");
|
||||
}
|
||||
|
||||
@@ -429,7 +429,8 @@ fn comments_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A table style's cell text formatting, whole-table shading and cell margins survive a save.
|
||||
/// A table style's cell text formatting, whole-table shading, cell margins and its header row and
|
||||
/// row band conditional formats (#146) survive a save.
|
||||
#[test]
|
||||
fn table_style_formatting_round_trips() {
|
||||
use wordcraft_doc::styles::TableStyleParts;
|
||||
@@ -440,6 +441,11 @@ fn table_style_formatting_round_trips() {
|
||||
cell_margins: Some([1.0, 14.4, 0.0, 14.4]),
|
||||
header_chr: CharProps { italic: Some(true), ..Default::default() },
|
||||
header_fill: Some(Rgb(0xFF, 0xFF, 0)),
|
||||
header_borders: Some(Borders::all(Border::single(1.5))),
|
||||
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
|
||||
band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() },
|
||||
band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
|
||||
band_size: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
d.styles.upsert(Style {
|
||||
|
||||
@@ -327,13 +327,16 @@ View|Macros|Record Macro|tools.recordMacro
|
||||
View|SharePoint|Properties|file.properties
|
||||
Table Design|Table Style Options|Table Style Options|table.look
|
||||
Table Design|Table Styles|Table Styles|table.style
|
||||
Table Design|Table Styles|New Table Style|table.newStyle
|
||||
Table Design|Table Styles|Modify Table Style|table.modifyStyle
|
||||
Table Design|Table Styles|Delete Table Style|table.deleteStyle
|
||||
Table Design|Table Styles|Shading|table.shading
|
||||
Table Design|Borders|Borders|table.borders
|
||||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
Table Layout|Table|Select Table|table.selectTable
|
||||
Table Layout|Table|Select Row|table.selectRow
|
||||
Table Layout|Table|Select Cell|table.selectCell
|
||||
Table Layout|Table|View Gridlines|view.gridlines
|
||||
Table Layout|Table|View Gridlines|table.viewGridlines
|
||||
Table Layout|Table|Properties|table.properties
|
||||
Table Layout|Draw|Draw Table|table.draw
|
||||
Table Layout|Draw|Eraser|table.eraser
|
||||
@@ -500,6 +503,6 @@ mod tests {
|
||||
let p = parity(®);
|
||||
let pct = p["percent"].as_f64().unwrap();
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 60.0, "parity {pct}%");
|
||||
assert!(pct >= 89.7, "parity {pct}%");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 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.
|
||||
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
match id {
|
||||
"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" => per_row(s, run, v, &segs),
|
||||
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
|
||||
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
|
||||
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(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
+194
-16
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Pos, StoryRef};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -32,10 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"text"?: string}"#)
|
||||
.key("Mod+Shift+Alt+V"),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
|
||||
s.view.clipboard_pane = on;
|
||||
Ok(json!({"value": on, "count": s.clip_history.len()}))
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
|
||||
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips),
|
||||
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
|
||||
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
|
||||
let i = clip_index(s, v)?;
|
||||
s.clip_history.items.remove(i);
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
|
||||
s.clip_history.items.clear();
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
|
||||
.key("Mod+F")
|
||||
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3").pure(),
|
||||
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)).key("Mod+Shift+G").pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
|
||||
@@ -63,6 +91,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
@@ -81,24 +110,142 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, Some(f)) => f.clone(),
|
||||
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
};
|
||||
if let Some(images) = v.get("image").and_then(Value::as_str) {
|
||||
let _ = images;
|
||||
}
|
||||
let at = delete_selection(s)?;
|
||||
let mut frag = frag;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
if let Block::Para(p) = b {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most items the Clipboard pane keeps.
|
||||
pub const CLIP_MAX_ITEMS: usize = 24;
|
||||
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
|
||||
const CLIP_ITEM_BYTES: usize = 4 << 20;
|
||||
/// All collected items together stay under this; the oldest go first.
|
||||
const CLIP_TOTAL_BYTES: usize = 24 << 20;
|
||||
/// Characters of preview per item.
|
||||
const CLIP_PREVIEW_CHARS: usize = 120;
|
||||
|
||||
/// One item collected by Copy or Cut.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipItem {
|
||||
pub fragment: Fragment,
|
||||
/// Its plain text.
|
||||
pub text: String,
|
||||
/// Rough memory use.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ClipItem {
|
||||
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
|
||||
pub fn preview(&self) -> String {
|
||||
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
|
||||
let mut out = String::new();
|
||||
let mut n = 0;
|
||||
for w in words {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
n += 1;
|
||||
}
|
||||
for c in w.chars() {
|
||||
if n >= CLIP_PREVIEW_CHARS {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
out.push(c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
|
||||
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClipHistory {
|
||||
items: Vec<ClipItem>,
|
||||
}
|
||||
|
||||
impl ClipHistory {
|
||||
pub fn items(&self) -> &[ClipItem] {
|
||||
&self.items
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Copying the same thing again doesn't add a second item.
|
||||
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
|
||||
return;
|
||||
}
|
||||
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
|
||||
self.items.truncate(CLIP_MAX_ITEMS);
|
||||
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
|
||||
self.items.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The items for agents and the pane, newest first.
|
||||
fn describe(&self) -> Value {
|
||||
Value::Array(
|
||||
self.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
json!({
|
||||
"index": i,
|
||||
"preview": it.preview(),
|
||||
"chars": it.text.chars().count(),
|
||||
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_clips(s: &Session) -> Option<&'static str> {
|
||||
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
|
||||
}
|
||||
|
||||
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
|
||||
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
|
||||
usize::try_from(i)
|
||||
.ok()
|
||||
.filter(|i| *i < s.clip_history.len())
|
||||
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
|
||||
}
|
||||
|
||||
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
|
||||
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
|
||||
let saved = s.clipboard.replace(f);
|
||||
let r = paste(s, &json!({}));
|
||||
s.clipboard = saved;
|
||||
r
|
||||
}
|
||||
|
||||
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let i = clip_index(s, v)?;
|
||||
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
|
||||
paste_clip(s, f)
|
||||
}
|
||||
|
||||
/// Every collected item, in the order they were copied (one undo step).
|
||||
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
|
||||
let mut r = sel_result(s)?;
|
||||
for f in all {
|
||||
r = paste_clip(s, f)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -115,7 +262,7 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// paragraph without it: a match lies within one stretch of live text, and word
|
||||
/// boundaries and anchors see the neighbouring live text, not the deleted text.
|
||||
/// Otherwise Replace would find its own deleted text again.
|
||||
fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
||||
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
||||
use regex_automata::{Input, meta, util::syntax};
|
||||
let f = &s.find;
|
||||
if f.query.is_empty() {
|
||||
@@ -160,6 +307,8 @@ fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
||||
}
|
||||
|
||||
fn read_opts(s: &mut Session, v: &Value) {
|
||||
// A new search changes what Reading Highlight marks.
|
||||
s.find.highlights = None;
|
||||
if let Some(t) = p::str(v, "text") {
|
||||
s.find.query = t.to_string();
|
||||
}
|
||||
@@ -199,6 +348,35 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
|
||||
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
read_opts(s, v);
|
||||
let within = match p::str(v, "in").unwrap_or("main") {
|
||||
"main" => None,
|
||||
"selection" if !s.sel.is_collapsed() => Some(s.sel.ordered()),
|
||||
"selection" => return Err(CmdError::Params("`in: selection` needs a selection".into())),
|
||||
other => return Err(CmdError::Params(format!("`in` is main or selection, not `{other}`"))),
|
||||
};
|
||||
let story = within.as_ref().map(|(a, _)| a.story).unwrap_or(StoryRef::Body);
|
||||
let mut found = search(s, story)?;
|
||||
if let Some((a, b)) = &within {
|
||||
found.retain(|(x, y)| x >= a && y <= b);
|
||||
}
|
||||
if let Some(h) = p::bool(v, "highlight") {
|
||||
s.find.highlight = h;
|
||||
s.find.highlights = None;
|
||||
}
|
||||
let n = found.len();
|
||||
let matches: Vec<Value> = found.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect();
|
||||
s.find.current = 0;
|
||||
s.find.results = found;
|
||||
Ok(json!({"count": n, "matches": matches, "highlight": s.find.highlight}))
|
||||
}
|
||||
|
||||
fn step(s: &mut Session, dir: i64) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
s.find.results = search(s, story)?;
|
||||
|
||||
@@ -131,12 +131,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
};
|
||||
s.doc.core.modified = super::now_iso();
|
||||
if s.doc.core.created.is_empty() {
|
||||
s.doc.core.created = s.doc.core.modified.clone();
|
||||
}
|
||||
s.doc.core.last_modified_by = s.author.clone();
|
||||
s.doc.core.revision = s.doc.core.revision.saturating_add(1);
|
||||
s.stamp_save();
|
||||
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
|
||||
|
||||
@@ -160,8 +160,28 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
let segs = s.column_segments()?;
|
||||
let rows: Vec<(Pos, Pos)> = segs
|
||||
.iter()
|
||||
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
|
||||
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
|
||||
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
|
||||
.collect();
|
||||
// An empty block (column mode just started) formats like a caret.
|
||||
(!rows.is_empty()).then_some(rows)
|
||||
}
|
||||
|
||||
/// Apply a change to the selection (or the pending caret formatting).
|
||||
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
// A column selection formats each row's piece.
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
match target(s) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
@@ -186,6 +206,22 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
/// Resolved formatting of every character in the target (or the caret).
|
||||
fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
|
||||
}
|
||||
}
|
||||
if out.len() > 10_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let Some((a, b)) = target(s) else {
|
||||
let f = &s.sel.focus;
|
||||
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Style Inspector (Home › Styles): the paragraph style and the character style at the caret,
|
||||
//! the direct formatting layered on each, and a way to clear each level.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
use wordcraft_doc::props::{CharProps, ParaProps};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The levels `styles.inspectorClear` can reset.
|
||||
pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"];
|
||||
|
||||
/// Direct character properties that aren't formatting (links, revisions, proofing, direction).
|
||||
const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"];
|
||||
/// Direct paragraph properties the inspector doesn't list (lists have their own UI).
|
||||
const PARA_SKIP: [&str; 2] = ["style", "numbering"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("styles.inspector", "Style Inspector", "Home › Styles", |s, v| {
|
||||
s.view.style_inspector = p::bool(v, "value").unwrap_or(!s.view.style_inspector);
|
||||
let mut out = inspect(s);
|
||||
if let Value::Object(m) = &mut out {
|
||||
m.insert("pane".into(), json!(s.view.style_inspector));
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.params(r#"{"value"?: bool (show/hide the pane)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(),
|
||||
CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear)
|
||||
.params(r#"{"level": "paragraphStyle|paragraphFormatting|characterStyle|characterFormatting"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// The paragraph props at the caret (the selection start for a selection).
|
||||
fn para_props(s: &Session) -> ParaProps {
|
||||
let (a, _) = s.sel.ordered();
|
||||
s.doc.para_at(&a).map(|p| p.props.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The direct character props at the caret (the first selected character for a selection).
|
||||
fn char_props(s: &Session) -> CharProps {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return s.typing_props();
|
||||
}
|
||||
match s.doc.para_at(&a) {
|
||||
Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(),
|
||||
_ => s.typing_props(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each set field of `direct` (minus `skip`) whose value changes the result of `resolve`, given
|
||||
/// a props value holding only that field. Returns `[{"prop", "value"}]` in field order.
|
||||
fn differences<T: Serialize + DeserializeOwned>(direct: &T, skip: &[&str], base: &Value, resolve: &dyn Fn(T) -> Value) -> Vec<Value> {
|
||||
let Ok(Value::Object(fields)) = serde_json::to_value(direct) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in fields {
|
||||
if skip.contains(&k.as_str()) || v.is_null() {
|
||||
continue;
|
||||
}
|
||||
let mut one = Map::new();
|
||||
one.insert(k.clone(), v.clone());
|
||||
let Ok(single) = serde_json::from_value::<T>(Value::Object(one)) else { continue };
|
||||
if resolve(single) != *base {
|
||||
out.push(json!({"prop": k, "value": v}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn style_name(s: &Session, id: &str) -> String {
|
||||
s.doc.styles.get(id).map(|st| st.name.clone()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// What the Style Inspector shows, as JSON.
|
||||
pub fn inspect(s: &Session) -> Value {
|
||||
let pp = para_props(s);
|
||||
let pstyle = pp.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
let styles = &s.doc.styles;
|
||||
let para_base = ParaProps { style: Some(pstyle.clone()), ..Default::default() };
|
||||
let pbase = serde_json::to_value(styles.resolve_para(¶_base)).unwrap_or(Value::Null);
|
||||
let pdiff = differences(&pp, &PARA_SKIP, &pbase, &|mut one: ParaProps| {
|
||||
one.style = Some(pstyle.clone());
|
||||
serde_json::to_value(styles.resolve_para(&one)).unwrap_or(Value::Null)
|
||||
});
|
||||
|
||||
let cp = char_props(s);
|
||||
let cstyle = cp.style.clone();
|
||||
let char_base = CharProps { style: cstyle.clone(), ..Default::default() };
|
||||
let cbase = serde_json::to_value(styles.resolve_char(Some(&pstyle), &char_base)).unwrap_or(Value::Null);
|
||||
let cdiff = differences(&cp, &CHAR_SKIP, &cbase, &|mut one: CharProps| {
|
||||
one.style = cstyle.clone();
|
||||
serde_json::to_value(styles.resolve_char(Some(&pstyle), &one)).unwrap_or(Value::Null)
|
||||
});
|
||||
|
||||
json!({
|
||||
"paragraph": {
|
||||
"style": pstyle,
|
||||
"styleName": style_name(s, &pstyle),
|
||||
"direct": pdiff,
|
||||
},
|
||||
"character": {
|
||||
"style": cstyle,
|
||||
"styleName": cstyle.as_deref().map(|c| style_name(s, c)),
|
||||
"direct": cdiff,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let level = p::req_str(v, "level")?;
|
||||
let (a, b) = s.sel.ordered();
|
||||
match level {
|
||||
"paragraphStyle" => {
|
||||
// Reset to Normal, keeping direct formatting.
|
||||
s.doc.format_paragraphs(&a, &b, &|p| p.style = None)?;
|
||||
}
|
||||
"paragraphFormatting" => {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
|
||||
})?;
|
||||
}
|
||||
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
|
||||
"characterFormatting" => {
|
||||
return super::format::apply(s, &|c| {
|
||||
*c = CharProps {
|
||||
style: c.style.clone(),
|
||||
link: c.link.clone(),
|
||||
ins: c.ins,
|
||||
del: c.del,
|
||||
lang: c.lang.clone(),
|
||||
no_proof: c.no_proof,
|
||||
rtl: c.rtl,
|
||||
..Default::default()
|
||||
};
|
||||
});
|
||||
}
|
||||
other => return Err(CmdError::Params(format!("unknown level `{other}`; expected one of {}", LEVELS.join(", ")))),
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
pub mod caret;
|
||||
pub mod citations;
|
||||
pub mod column;
|
||||
pub mod design;
|
||||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod file;
|
||||
pub mod format;
|
||||
pub mod insert;
|
||||
pub mod inspector;
|
||||
pub mod mailings;
|
||||
pub mod objects;
|
||||
pub mod page;
|
||||
pub mod para;
|
||||
pub mod paste;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
pub mod table_style;
|
||||
pub mod text;
|
||||
pub mod tools;
|
||||
pub mod view;
|
||||
@@ -31,14 +35,18 @@ pub fn registry() -> Registry {
|
||||
let mut v = Vec::new();
|
||||
v.extend(text::specs());
|
||||
v.extend(caret::specs());
|
||||
v.extend(column::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(para::specs());
|
||||
v.extend(inspector::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(page::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(table_style::specs());
|
||||
v.extend(review::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(design::specs());
|
||||
@@ -116,7 +124,7 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
|
||||
}
|
||||
|
||||
/// Tracked deletion: own insertions are removed, other text is marked deleted.
|
||||
fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
let rid = new_revision(s, RevisionKind::Delete);
|
||||
let author = s.author.clone();
|
||||
// Remove text this author inserted (it never existed for the reader); mark the rest.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
|
||||
//!
|
||||
//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
|
||||
//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
|
||||
//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
|
||||
//! can also be pasted as that format.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Document, Paragraph};
|
||||
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Largest clipboard payload Paste Special accepts, in bytes.
|
||||
pub const MAX_PASTE_BYTES: usize = 16 << 20;
|
||||
/// Nesting depth we follow when adjusting pasted tables.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The formats, in the order the dialog lists them: (id, label).
|
||||
pub const FORMATS: [(&str, &str); 4] =
|
||||
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
|
||||
.key("Mod+Alt+V")
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clipboard text from the params (system clipboard), else WordCraft's own copy.
|
||||
fn clip_text(s: &Session, v: &Value) -> Option<String> {
|
||||
match p::str(v, "text") {
|
||||
Some(t) => Some(t.replace("\r\n", "\n")),
|
||||
None => s.clipboard.as_ref().map(Fragment::plain_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_rtf(t: &str) -> bool {
|
||||
t.trim_start().starts_with("{\\rtf")
|
||||
}
|
||||
|
||||
fn looks_like_html(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
t.starts_with('<') && (t.contains("</") || t.contains("/>"))
|
||||
}
|
||||
|
||||
/// The source for a format, if the clipboard has it.
|
||||
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
|
||||
match fmt {
|
||||
"formatted" => {
|
||||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
|
||||
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
|
||||
"text" => clip_text(s, v).and_then(non_empty),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the clipboard can be pasted as, best first.
|
||||
pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {
|
||||
FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()
|
||||
}
|
||||
|
||||
fn formats_json(ids: &[&str]) -> Value {
|
||||
json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) {
|
||||
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
|
||||
}
|
||||
}
|
||||
let ids = available(s, v);
|
||||
let Some(fmt) = p::str(v, "as") else {
|
||||
// No format chosen: list them and ask a front end (if any) to show the dialog.
|
||||
let mut req = json!({"open": "pasteSpecial", "formats": ids});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = p::str(v, k) {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
}
|
||||
s.ui_requests.push(req);
|
||||
return Ok(json!({"formats": formats_json(&ids)}));
|
||||
};
|
||||
let fmt = match fmt.to_ascii_lowercase().as_str() {
|
||||
"formatted" | "keep" | "wordcraft" => "formatted",
|
||||
"rtf" => "rtf",
|
||||
"html" => "html",
|
||||
"text" | "unformatted" | "plain" => "text",
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
|
||||
};
|
||||
let Some(src) = source(s, v, fmt) else {
|
||||
return Err(CmdError::Failed(if ids.is_empty() {
|
||||
"the clipboard is empty".into()
|
||||
} else {
|
||||
format!("the clipboard has no {fmt} content (available: {})", ids.join(", "))
|
||||
}));
|
||||
};
|
||||
let frag = match fmt {
|
||||
"text" => {
|
||||
super::type_text(s, &src.replace('\n', "\r"))?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
return Ok(r);
|
||||
}
|
||||
"formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
|
||||
"html" => adopt(s, wordcraft_formats::html::import(&src)),
|
||||
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
|
||||
};
|
||||
insert(s, frag)?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
|
||||
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
r.props.del = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every paragraph in a block, tables included (bounded depth).
|
||||
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
for row in &mut t.rows {
|
||||
for cell in &mut row.cells {
|
||||
for cb in &mut cell.blocks {
|
||||
each_para(Arc::make_mut(cb), depth + 1, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn an imported document into a fragment of the session's document: bring over the media,
|
||||
/// lists and styles it refers to (renumbered so they can't collide with ours).
|
||||
fn adopt(s: &mut Session, src: Document) -> Fragment {
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
abs_map.insert(a.id, id);
|
||||
s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
|
||||
}
|
||||
let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut num_map = BTreeMap::new();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
num_map.insert(n.id, id);
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
|
||||
}
|
||||
for st in &src.styles.styles {
|
||||
if s.doc.styles.get(&st.id).is_none() {
|
||||
s.doc.styles.upsert(st.clone());
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
for b in &mut blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
if let Some(n) = p.props.numbering.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = num_map.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
|
||||
&& let Some(k) = media.get(key.as_str())
|
||||
{
|
||||
*key = k.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Text box stories the imported shapes show; pasting gives each a new story of its own.
|
||||
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)),
|
||||
t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| {
|
||||
let st = p::req_str(v, "style")?;
|
||||
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
|
||||
let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
|
||||
with_table(s, |t| t.props.style = Some(id.clone()))
|
||||
})
|
||||
.params(r#"{"style": string}"#)),
|
||||
@@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
|
||||
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
|
||||
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
|
||||
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
|
||||
with_cells(s, |cl| cl.props.text_direction = dir)
|
||||
}
|
||||
|
||||
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
|
||||
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
|
||||
pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
|
||||
let story = s.sel.focus.story;
|
||||
let paths: Vec<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
|
||||
for p in paths {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
|
||||
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
|
||||
//! document's style sheet (ECMA-376 §17.7.6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The regions a table style formats, by param name: the table style options they follow.
|
||||
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
|
||||
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
|
||||
/// shortcuts.
|
||||
macro_rules! style_params {
|
||||
($head:literal) => {
|
||||
concat!(
|
||||
"{",
|
||||
$head,
|
||||
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
|
||||
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
|
||||
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
|
||||
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
|
||||
.params(r#"{"style"?: string (default: the current table's)}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count.
|
||||
pub fn table_style_id(s: &Session, name: &str) -> Option<String> {
|
||||
let st = &s.doc.styles.styles;
|
||||
st.iter()
|
||||
.find(|x| x.kind == StyleKind::Table && x.id == name)
|
||||
.or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name)))
|
||||
.map(|x| x.id.clone())
|
||||
}
|
||||
|
||||
/// The style of the table at the caret.
|
||||
fn current_table_style(s: &Session) -> Option<String> {
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
|
||||
}
|
||||
|
||||
/// The style `table.newStyle` would add for `v`, validated.
|
||||
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(CmdError::Params("name is empty".into()));
|
||||
}
|
||||
if s.doc.styles.find(&name).is_some() {
|
||||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||||
}
|
||||
let based_on = match p::str(v, "basedOn") {
|
||||
Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?),
|
||||
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The table style `v` names (`style`), else the current table's.
|
||||
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
match p::str(v, "style") {
|
||||
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
|
||||
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The style `table.modifyStyle` would store for `v`, validated.
|
||||
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let id = target(s, v)?;
|
||||
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &rename
|
||||
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
|
||||
{
|
||||
return Err(CmdError::Failed(format!("a style named `{n}` already exists")));
|
||||
}
|
||||
let based_on = match p::str(v, "basedOn") {
|
||||
Some(b) => {
|
||||
let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?;
|
||||
// A style can't be based on itself or on a style based on it.
|
||||
if s.doc.styles.chain(&b).iter().any(|x| x.id == id) {
|
||||
return Err(CmdError::Params("a style can't be based on itself".into()));
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if let Some(n) = rename {
|
||||
st.name = n;
|
||||
}
|
||||
if let Some(b) = based_on {
|
||||
st.based_on = Some(b);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
|
||||
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
|
||||
/// aren't checked; `None` when the params don't validate.
|
||||
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
let mut v = v.clone();
|
||||
let st = if p::str(&v, "style").is_some() {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("name");
|
||||
}
|
||||
build_modified(s, &v).ok()?
|
||||
} else {
|
||||
// A name no style has: the look doesn't depend on it.
|
||||
v["name"] = json!("\u{1}preview");
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
|
||||
/// Delete a custom table style. Styles based on it are re-based on its own base with its
|
||||
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
|
||||
/// tables get), or no style when the document has no Table Grid.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = target(s, v)?;
|
||||
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if is_builtin_table_style(&st) {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
let mut chr = st.chr.clone();
|
||||
chr.overlay(&child.chr);
|
||||
child.chr = chr;
|
||||
if child.kind == StyleKind::Table {
|
||||
let mut parts = st.table.clone().unwrap_or_default();
|
||||
if let Some(t) = &child.table {
|
||||
parts.overlay(t);
|
||||
}
|
||||
child.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
|
||||
}
|
||||
s.dirty = true;
|
||||
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
|
||||
}
|
||||
|
||||
/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom
|
||||
/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting
|
||||
/// them would leave new tables (and the fallback for deleted styles) without a style to take.
|
||||
pub fn is_builtin_table_style(st: &Style) -> bool {
|
||||
const IDS: [&str; 2] = ["TableGrid", "TableNormal"];
|
||||
const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"];
|
||||
st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x))
|
||||
}
|
||||
|
||||
/// Tables nest at most this deep for restyling.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Whether table `t`, or one nested in it, uses style `id`.
|
||||
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
|
||||
t.props.style.as_deref() == Some(id)
|
||||
|| (depth < MAX_DEPTH
|
||||
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
|
||||
}
|
||||
|
||||
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
|
||||
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
|
||||
let mut n = 0;
|
||||
for b in blocks {
|
||||
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
|
||||
continue;
|
||||
}
|
||||
if let Block::Table(t) = Arc::make_mut(b) {
|
||||
if t.props.style.as_deref() == Some(from) {
|
||||
t.props.style = to.map(str::to_string);
|
||||
n += 1;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
|
||||
n += restyle(&mut c.blocks, from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Apply style `id` to the table at the caret, if any.
|
||||
fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> {
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) };
|
||||
s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string());
|
||||
super::table::touch_cells(s, &tp)
|
||||
}
|
||||
|
||||
/// Apply the formatting params to `st`.
|
||||
fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
let mut parts = st.table.take().unwrap_or_default();
|
||||
if let Some(r) = v.get("wholeTable") {
|
||||
region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("headerRow") {
|
||||
region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedRows") {
|
||||
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastRow") {
|
||||
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("firstColumn") {
|
||||
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastColumn") {
|
||||
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedColumns") {
|
||||
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
|
||||
}
|
||||
if let Some(n) = v.get("bandSize") {
|
||||
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
|
||||
}
|
||||
if let Some(pp) = v.get("para") {
|
||||
let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?;
|
||||
st.para.overlay(&pp);
|
||||
st.para.style = None;
|
||||
st.para.numbering = None;
|
||||
}
|
||||
st.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One region's shortcuts: borders, fill and character formatting.
|
||||
fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut Option<Borders>) -> Result<(), CmdError> {
|
||||
if !r.is_object() {
|
||||
return Err(CmdError::Params("a region is an object".into()));
|
||||
}
|
||||
let color = |k: &str| -> Result<Option<Rgb>, CmdError> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
|
||||
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
|
||||
}
|
||||
};
|
||||
if r.get("fill").is_some() {
|
||||
*fill = color("fill")?;
|
||||
}
|
||||
if r.get("color").is_some() {
|
||||
chr.color = color("color")?.map(TextColor::Rgb);
|
||||
}
|
||||
if let Some(b) = p::bool(r, "bold") {
|
||||
chr.bold = Some(b);
|
||||
chr.bold_cs = Some(b);
|
||||
}
|
||||
if let Some(b) = p::bool(r, "italic") {
|
||||
chr.italic = Some(b);
|
||||
chr.italic_cs = Some(b);
|
||||
}
|
||||
if let Some(sz) = p::f32(r, "size") {
|
||||
chr.size = Some(sz.clamp(1.0, 1638.0));
|
||||
chr.size_cs = chr.size;
|
||||
}
|
||||
if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) {
|
||||
chr.font = Some(f.to_string());
|
||||
}
|
||||
match r.get("borders") {
|
||||
Some(Value::Bool(true)) => {
|
||||
let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
*borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 }));
|
||||
}
|
||||
Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
|
||||
Some(Value::Null) => *borders = None,
|
||||
Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())),
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -33,18 +33,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
|
||||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(),
|
||||
// The drawing grid over the page (screen only; never printed or exported).
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
// Outlines of table cells (screen only; never printed or exported).
|
||||
CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(),
|
||||
CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom)
|
||||
.params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(),
|
||||
CommandSpec::new("view.zoomIn", "Zoom In", "Status Bar", |s, _| {
|
||||
CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| {
|
||||
CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub use session::{EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_layout as layout;
|
||||
pub use wordcraft_render as render;
|
||||
|
||||
@@ -31,6 +31,19 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// A column (block) selection: the same x range on every line between two corners.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ColumnBlock {
|
||||
/// The stream selection the block was made with (its corners). The block is current only
|
||||
/// while `Session::sel` still equals it, so any ordinary caret move or selection drops it.
|
||||
pub sel: Selection,
|
||||
/// Page x of the anchor and focus corners (points); the block spans the range between.
|
||||
pub anchor_x: f32,
|
||||
pub focus_x: f32,
|
||||
/// One `(start, end)` per line, in document order, each inside one paragraph.
|
||||
pub segments: Vec<(Pos, Pos)>,
|
||||
}
|
||||
|
||||
/// View state that commands can change (ribbon View tab, status bar).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -41,10 +54,20 @@ pub struct ViewState {
|
||||
pub focus_mode: bool,
|
||||
pub marks: bool,
|
||||
pub ruler: bool,
|
||||
/// View › Show › Gridlines: the drawing grid over the page's text area (on screen only).
|
||||
pub gridlines: bool,
|
||||
/// Table Layout › View Gridlines: outlines of table cells (on screen only).
|
||||
#[serde(default)]
|
||||
pub table_gridlines: bool,
|
||||
pub nav_pane: bool,
|
||||
pub styles_pane: bool,
|
||||
/// Style Inspector pane (Home › Styles).
|
||||
#[serde(default)]
|
||||
pub style_inspector: bool,
|
||||
pub comments_pane: bool,
|
||||
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
|
||||
#[serde(default)]
|
||||
pub clipboard_pane: bool,
|
||||
pub multi_page: bool,
|
||||
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
|
||||
pub fit: String,
|
||||
@@ -66,9 +89,12 @@ impl Default for ViewState {
|
||||
marks: false,
|
||||
ruler: true,
|
||||
gridlines: false,
|
||||
table_gridlines: false,
|
||||
nav_pane: false,
|
||||
styles_pane: false,
|
||||
style_inspector: false,
|
||||
comments_pane: false,
|
||||
clipboard_pane: false,
|
||||
multi_page: false,
|
||||
fit: String::new(),
|
||||
web_width: 800.0,
|
||||
@@ -93,6 +119,11 @@ pub struct FindState {
|
||||
#[serde(skip)]
|
||||
pub results: Vec<(Pos, Pos)>,
|
||||
pub current: usize,
|
||||
/// Reading Highlight: mark every match of `query` in the body.
|
||||
pub highlight: bool,
|
||||
/// Highlighted matches, for the document revision they were found in.
|
||||
#[serde(skip)]
|
||||
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -152,6 +183,8 @@ pub struct Session {
|
||||
pub clipboard: Option<Fragment>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
/// Page x the caret tries to keep on Up/Down.
|
||||
pub goal_x: Option<f32>,
|
||||
@@ -210,6 +243,10 @@ pub struct Session {
|
||||
pub math_latex: bool,
|
||||
/// Text typed into equations is normal (non-math) text.
|
||||
pub math_normal_text: bool,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
pub column_mode: bool,
|
||||
}
|
||||
|
||||
/// The equation being edited.
|
||||
@@ -250,6 +287,7 @@ impl Session {
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
clipboard_text: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
@@ -283,9 +321,17 @@ impl Session {
|
||||
math_normal_text: false,
|
||||
prefs: Prefs::default(),
|
||||
read_aloud: Default::default(),
|
||||
column: None,
|
||||
column_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line pieces of the current column selection, if there is one (the selection hasn't
|
||||
/// moved since the block was made).
|
||||
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
|
||||
}
|
||||
|
||||
/// Document revision (bumped by every change).
|
||||
pub fn rev(&self) -> u64 {
|
||||
self.rev
|
||||
@@ -299,6 +345,18 @@ impl Session {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
|
||||
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
|
||||
if !self.find.highlight {
|
||||
return &[];
|
||||
}
|
||||
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
|
||||
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
|
||||
self.find.highlights = Some((self.rev, found));
|
||||
}
|
||||
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let ww = self.view.web_width;
|
||||
@@ -362,6 +420,18 @@ impl Session {
|
||||
pub fn join_next_undo(&mut self) {
|
||||
self.join_next = true;
|
||||
}
|
||||
/// Stamp the document's save metadata (modified time, last modified by, revision; created
|
||||
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
|
||||
/// front end that writes the bytes itself (the web build's download, #262).
|
||||
pub fn stamp_save(&mut self) {
|
||||
let core = &mut self.doc.core;
|
||||
core.modified = crate::cmd::now_iso();
|
||||
if core.created.is_empty() {
|
||||
core.created = core.modified.clone();
|
||||
}
|
||||
core.last_modified_by = self.author.clone();
|
||||
core.revision = core.revision.saturating_add(1);
|
||||
}
|
||||
/// Close an open typing group (caret moved, other command).
|
||||
pub fn close_typing(&mut self) {
|
||||
self.typing_open = false;
|
||||
@@ -415,6 +485,8 @@ impl Session {
|
||||
self.doc.ensure_nonempty();
|
||||
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
|
||||
self.pending = None;
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.reset_history();
|
||||
self.touch();
|
||||
self.dirty = false;
|
||||
@@ -447,6 +519,7 @@ impl Session {
|
||||
dirty,
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
page_hint: _,
|
||||
@@ -481,6 +554,9 @@ impl Session {
|
||||
math: _,
|
||||
math_latex: _,
|
||||
math_normal_text: _,
|
||||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||||
column: _,
|
||||
column_mode: _,
|
||||
} = self;
|
||||
let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() };
|
||||
EditSnapshot {
|
||||
@@ -581,7 +657,15 @@ impl Session {
|
||||
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
|
||||
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
|
||||
let before_doc = if spec.mutates {
|
||||
Some((self.doc.clone(), self.sel.clone(), self.history.clone(), self.redo.clone(), self.typing_open, self.undo_evicted))
|
||||
Some((
|
||||
self.doc.clone(),
|
||||
self.sel.clone(),
|
||||
self.history.clone(),
|
||||
self.redo.clone(),
|
||||
self.typing_open,
|
||||
self.undo_evicted,
|
||||
self.column.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -600,7 +684,8 @@ impl Session {
|
||||
self.typing_open = false;
|
||||
}
|
||||
let run = spec.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
|
||||
let mutates = spec.mutates;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -618,7 +703,7 @@ impl Session {
|
||||
self.clamp_selection();
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some((d, s, h, r, t, ev)) = before_doc {
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
self.doc = d;
|
||||
self.sel = s;
|
||||
self.history = h;
|
||||
@@ -626,6 +711,7 @@ impl Session {
|
||||
self.typing_open = t;
|
||||
// The steps the command pushed out are back, so they no longer count as gone.
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
}
|
||||
self.status = e.to_string();
|
||||
}
|
||||
|
||||
@@ -523,6 +523,151 @@ fn clipboard_round_trip() {
|
||||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold words"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
// No format: the formats are listed (and a front end is asked to show the dialog).
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["formatted", "text"]);
|
||||
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
|
||||
assert_eq!(r["pastedAs"], "text");
|
||||
assert_eq!(text(&s), "Bold wordsBold");
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
|
||||
// Formatted keeps it.
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
|
||||
assert_eq!(text(&s), "Bold wordsBoldBold");
|
||||
assert!(bold_at(&s, 0, 15));
|
||||
// Text that someone else copied since: our rich copy is no longer offered.
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
|
||||
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_html_and_rtf_keep_bold() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let html = "<p>plain <b>strong</b></p>";
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["html", "text"]);
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
|
||||
assert_eq!(text(&s), "xplain strong");
|
||||
assert!(!bold_at(&s, 0, 2));
|
||||
assert!(bold_at(&s, 0, 8), "the <b> run stays bold");
|
||||
// HTML source copied as text can be pasted as HTML too.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "<b>hi</b> there</p>"}));
|
||||
assert_eq!(text(&s), "hi there");
|
||||
assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
|
||||
// RTF.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
|
||||
assert!(text(&s).starts_with("bold"), "{}", text(&s));
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
// Asking for a format the clipboard lacks is an error, not a panic.
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
|
||||
let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "<ul><li>one</li><li>two</li></ul>"}));
|
||||
assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
|
||||
let ch = run(&mut s, "review.changes", json!({}));
|
||||
assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
let n = first.props.numbering.expect("list kept");
|
||||
assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert!(text(&s).starts_with("one\ntwo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one two three"}));
|
||||
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
}
|
||||
// Copying the same text again adds nothing.
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
|
||||
assert_eq!(previews, ["three", "two", "one"], "newest first");
|
||||
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
|
||||
assert!(s.view.clipboard_pane);
|
||||
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
|
||||
assert_eq!(text(&s), "one two threetwo");
|
||||
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three");
|
||||
|
||||
run(&mut s, "edit.pasteAllClipboard", json!({}));
|
||||
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
|
||||
|
||||
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
|
||||
assert_eq!(left[0]["preview"], "two");
|
||||
run(&mut s, "edit.clearClipboard", json!({}));
|
||||
assert!(s.clip_history.is_empty());
|
||||
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
|
||||
let mut s = s();
|
||||
let words: Vec<String> = (0..30).map(|i| format!("w{i}")).collect();
|
||||
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
|
||||
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
|
||||
for (off, w) in words.iter().enumerate() {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
}
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let items = items.as_array().unwrap();
|
||||
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
|
||||
assert_eq!(items[0]["preview"], "w29");
|
||||
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
|
||||
|
||||
let before = text(&s);
|
||||
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
|
||||
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
|
||||
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(text(&s), before);
|
||||
assert_eq!(s.clip_history.len(), 24);
|
||||
|
||||
// A huge copy still reaches the clipboard but is not collected.
|
||||
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(s.clipboard_text.len(), 3 << 20);
|
||||
assert_eq!(s.clip_history.items()[0].text, "w29");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_commands() {
|
||||
let mut s = s();
|
||||
@@ -1687,6 +1832,357 @@ fn styles_apply_rejected_on_protected_document() {
|
||||
assert_eq!(s.undo_labels(), undo);
|
||||
}
|
||||
|
||||
fn column_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn block(s: &mut Session) {
|
||||
let a = serde_json::to_value(Pos::body(0, 2)).unwrap();
|
||||
let f = serde_json::to_value(Pos::body(2, 4)).unwrap();
|
||||
let r = run(s, "select.column", json!({"anchor": a, "focus": f}));
|
||||
assert_eq!(r["rows"], json!(["cd", "cd", "cd"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_copies_the_block() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||||
assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3));
|
||||
// A caret move drops the block; pasting the copy gives three paragraphs.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
assert!(s.column_segments().is_none());
|
||||
s.autocorrect_on = false;
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_deletes_only_the_block() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||||
assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef");
|
||||
// Cut copies and deletes; typing replaces the block.
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
block(&mut s);
|
||||
run(&mut s, "text.insert", json!({"text": "X"}));
|
||||
assert_eq!(text(&s), "abXef\nabef\nabef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_formats_each_row() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
for i in 0..3 {
|
||||
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
|
||||
assert_ne!(p.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p.props_of_char(2).bold, Some(true));
|
||||
assert_eq!(p.props_of_char(3).bold, Some(true));
|
||||
assert_ne!(p.props_of_char(4).bold, Some(true));
|
||||
}
|
||||
// The block survives formatting, and a second Bold turns it off again.
|
||||
assert!(s.column_segments().is_some());
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true));
|
||||
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
|
||||
assert_eq!(text(&s), "abCDef\nabCDef\nabCDef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_mode_extends_with_the_arrow_keys() {
|
||||
let mut s = column_doc();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
|
||||
let r = run(&mut s, "select.column", json!({}));
|
||||
assert_eq!(r["columnMode"], true);
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
let r = run(&mut s, "caret.down", json!({}));
|
||||
assert_eq!(r["rows"], json!(["bc", "bc", "bc"]));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(text(&s), "adef\nadef\nadef");
|
||||
assert!(!s.column_mode);
|
||||
// Escape leaves the mode.
|
||||
run(&mut s, "select.column", json!({}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
assert!(!s.column_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_from_points() {
|
||||
let mut s = column_doc();
|
||||
let l = s.layout();
|
||||
let a = l.caret(&Pos::body(0, 1)).unwrap();
|
||||
let b = l.caret(&Pos::body(1, 5)).unwrap();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"select.column",
|
||||
json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}),
|
||||
);
|
||||
assert_eq!(r["rows"], json!(["bcde", "bcde"]));
|
||||
for bad in [
|
||||
json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}),
|
||||
json!({"from": 1, "to": "x"}),
|
||||
json!({"anchor": 1, "focus": {}}),
|
||||
] {
|
||||
assert!(s.run("select.column", &bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_inspector_reports_and_clears_levels() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Chapter one"}));
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
assert_eq!(r["paragraph"]["styleName"], "Heading 1");
|
||||
let pdirect = r["paragraph"]["direct"].as_array().unwrap();
|
||||
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
|
||||
assert!(r["character"]["style"].is_null());
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
|
||||
|
||||
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
|
||||
run(&mut s, "format.size", json!({"size": 20}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
|
||||
|
||||
// Character style level.
|
||||
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["style"], "Emphasis");
|
||||
assert_eq!(r["character"]["styleName"], "Emphasis");
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"}));
|
||||
assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null());
|
||||
|
||||
// Clearing character formatting removes the bold and keeps the paragraph levels.
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap());
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty());
|
||||
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"}));
|
||||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal");
|
||||
|
||||
// Each clear is one undo step.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1");
|
||||
assert_eq!(text(&s), "Chapter one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_inspector_pane_and_hostile_params() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let r = run(&mut s, "styles.inspector", json!({}));
|
||||
assert_eq!(r["pane"], true);
|
||||
assert!(s.view.style_inspector);
|
||||
assert_eq!(r["paragraph"]["styleName"], "Title");
|
||||
let r = run(&mut s, "styles.inspector", json!({"value": false}));
|
||||
assert_eq!(r["pane"], false);
|
||||
let before = text(&s);
|
||||
for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] {
|
||||
assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}");
|
||||
}
|
||||
assert_eq!(text(&s), before);
|
||||
assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok());
|
||||
// A selection reaching far outside the document.
|
||||
s.sel.focus = Pos::body(9999, 99999);
|
||||
s.sel.anchor = Pos::body(0, 0);
|
||||
assert!(s.run("styles.inspect", &json!({})).is_ok());
|
||||
assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok());
|
||||
}
|
||||
|
||||
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
|
||||
#[test]
|
||||
fn advanced_find_lists_and_highlights_matches() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Cat"}));
|
||||
// From the UI it opens the dialog.
|
||||
run(&mut s, "edit.advancedFind", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
|
||||
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
|
||||
assert_eq!(r["count"], 3);
|
||||
assert_eq!(s.find_highlights().len(), 3);
|
||||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
|
||||
// Highlights follow edits and the latest search.
|
||||
assert_eq!(s.find_highlights().len(), 2);
|
||||
run(&mut s, "text.insert", json!({"text": " cat"}));
|
||||
assert_eq!(s.find_highlights().len(), 3);
|
||||
// Only inside the selection.
|
||||
run(&mut s, "select.text", json!({"text": "cat dog"}));
|
||||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
|
||||
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
|
||||
assert!(s.find_highlights().is_empty());
|
||||
// Hostile params.
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
|
||||
}
|
||||
|
||||
/// #146: a table style made by command styles the table at the caret (header row fill and bold
|
||||
/// text, banded rows), can be modified, and saves as a table style the table refers to.
|
||||
#[test]
|
||||
fn custom_table_style_applies_modifies_and_saves() {
|
||||
use wordcraft_doc::{Rgb, StyleKind, TextColor};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "Head"}));
|
||||
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Thesis Table", "basedOn": "Table Grid",
|
||||
"wholeTable": {"size": 10},
|
||||
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
|
||||
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
|
||||
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
|
||||
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
|
||||
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
|
||||
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
let head = |s: &mut Session| {
|
||||
let mut p = tp.0.clone();
|
||||
p.extend([0, 0, 0]);
|
||||
s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(
|
||||
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
|
||||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
|
||||
let rc = head(&mut s);
|
||||
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
|
||||
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
|
||||
|
||||
// Modify the current table's style: the table follows.
|
||||
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
|
||||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
|
||||
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
|
||||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
|
||||
assert!(!head(&mut s).bold);
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let st = back.styles.get(&id).unwrap();
|
||||
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
|
||||
let parts = st.table.as_ref().unwrap();
|
||||
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
|
||||
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
|
||||
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
|
||||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_and_table_gridlines_toggle_independently() {
|
||||
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
|
||||
// outlines) are separate view switches; neither touches the document or the undo stack.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let undo = s.undo_labels();
|
||||
assert!(!s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true);
|
||||
assert!(s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
// Works with the caret outside a table.
|
||||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true);
|
||||
assert!(s.view.gridlines && s.view.table_gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||||
assert!(!s.view.gridlines && s.view.table_gridlines);
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||||
assert!(!s.view.gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false);
|
||||
assert!(!s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
assert_eq!(text(&s), "Hello");
|
||||
assert_eq!(s.undo_labels(), undo);
|
||||
let state = run(&mut s, "view.state", json!({}));
|
||||
assert_eq!(state["gridlines"], false);
|
||||
assert_eq!(state["tableGridlines"], false);
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
|
||||
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
|
||||
/// short of the 10%–500% limits.
|
||||
#[test]
|
||||
fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() {
|
||||
let mut s = s();
|
||||
let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32;
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert_eq!(pct(&s), 110);
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert_eq!(pct(&s), 90);
|
||||
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
|
||||
run(&mut s, fit, json!({}));
|
||||
assert!(!s.view.fit.is_empty());
|
||||
// What the canvas reports while a fit mode shows the page at 163%.
|
||||
s.view.zoom = 1.63;
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}");
|
||||
run(&mut s, fit, json!({}));
|
||||
s.view.zoom = 1.63;
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}");
|
||||
}
|
||||
let mut last = pct(&s);
|
||||
for _ in 0..60 {
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert!(pct(&s) > last || pct(&s) == 500);
|
||||
last = pct(&s);
|
||||
}
|
||||
assert_eq!(last, 500);
|
||||
for _ in 0..60 {
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert!(pct(&s) < last || pct(&s) == 10);
|
||||
last = pct(&s);
|
||||
}
|
||||
assert_eq!(last, 10);
|
||||
run(&mut s, "view.zoom100", json!({}));
|
||||
assert_eq!(pct(&s), 100);
|
||||
}
|
||||
|
||||
/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01).
|
||||
#[test]
|
||||
fn timestamps_come_from_the_clock() {
|
||||
@@ -1767,3 +2263,90 @@ fn style_visibility_round_trips_through_docx() {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
fn custom_table_style_column_and_total_regions() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
|
||||
run(
|
||||
&mut s,
|
||||
"table.look",
|
||||
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
|
||||
);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Ledger",
|
||||
"firstColumn": {"fill": "112233", "bold": true},
|
||||
"lastColumn": {"fill": "445566"},
|
||||
"lastRow": {"fill": "778899", "borders": true},
|
||||
"bandedColumns": {"fill": "ABCDEF"}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
|
||||
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
|
||||
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
|
||||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
|
||||
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
|
||||
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
|
||||
assert_eq!(
|
||||
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
|
||||
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
|
||||
);
|
||||
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
|
||||
}
|
||||
|
||||
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
|
||||
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
|
||||
#[test]
|
||||
fn delete_table_style_falls_back_and_undoes() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
|
||||
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
|
||||
|
||||
let r = run(&mut s, "table.deleteStyle", json!({}));
|
||||
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert!(s.doc.styles.get("Base").is_none());
|
||||
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
|
||||
let child = s.doc.styles.table_style("Child").unwrap();
|
||||
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
|
||||
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Base").is_some());
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
|
||||
}
|
||||
|
||||
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
|
||||
/// no table is left pointing at a style that's gone.
|
||||
#[test]
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
|
||||
}
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
|
||||
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
|
||||
use wordcraft_doc::{Document, Path, StoryRef};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Document, Path, StoryRef};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
@@ -82,6 +83,14 @@ pub enum Draw {
|
||||
turn: TextDirection,
|
||||
items: Vec<Draw>,
|
||||
},
|
||||
/// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at
|
||||
/// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark.
|
||||
MarkText {
|
||||
x: f32,
|
||||
baseline: f32,
|
||||
size: f32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Draw {
|
||||
@@ -343,26 +352,36 @@ fn lines(
|
||||
if matches!(c.kind, ClKind::Text | ClKind::Space) {
|
||||
let a = text.len();
|
||||
// An object's cluster (a field, a note number) stands for the text it shows.
|
||||
let mut shown = false;
|
||||
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _)| *i)
|
||||
&& let Some((_, s)) = pl.shown.get(i)
|
||||
let mut shown = None;
|
||||
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i)
|
||||
&& let Some((_, s, r)) = pl.shown.get(i)
|
||||
{
|
||||
text.push_str(s);
|
||||
shown = true;
|
||||
shown = Some(r);
|
||||
} else if let Some(p) = para {
|
||||
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
|
||||
}
|
||||
let b = text.len();
|
||||
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
|
||||
if shown && cg.len() > 1 {
|
||||
// A whole field result is one cluster of many glyphs: give them a character
|
||||
// each (the last takes any remainder), not the whole text as one ligature.
|
||||
if let Some(shown) = shown
|
||||
&& cg.len() > 1
|
||||
{
|
||||
// A whole field result is one cluster of many glyphs: each glyph gets the
|
||||
// text it was shaped from (a ligature its letters), not the whole text as
|
||||
// one ligature. Without that, a character each (the last takes any remainder).
|
||||
let bounds: Vec<usize> = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect();
|
||||
let known = shown.len() == cg.len();
|
||||
for (n, g) in cg.iter().enumerate() {
|
||||
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
|
||||
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
|
||||
let r = match shown.get(n).filter(|_| known) {
|
||||
Some(r) => (a + r.start).min(b)..(a + r.end).min(b),
|
||||
None => {
|
||||
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
|
||||
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
|
||||
from..to
|
||||
}
|
||||
};
|
||||
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
|
||||
ranges.push(from..to.max(from));
|
||||
ranges.push(r.start..r.end.max(r.start));
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
@@ -558,7 +577,9 @@ fn lines(
|
||||
}
|
||||
}
|
||||
if line.end == LineEnd::Para {
|
||||
let ex = x + line.end_x();
|
||||
// The mark follows the line's visual end, past every glyph, even where the
|
||||
// logically last text runs against the paragraph's direction.
|
||||
let ex = x + line.visual_end_x();
|
||||
let size = pl.lines.first().map(|_| msize).unwrap_or(msize);
|
||||
// A right-to-left paragraph's mark sits at its end, on the left.
|
||||
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
|
||||
@@ -567,13 +588,75 @@ fn lines(
|
||||
.and_then(|p| p.mark.ins.or(p.mark.del))
|
||||
.filter(|_| opts.markup)
|
||||
.map(|r| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)));
|
||||
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color });
|
||||
// The last paragraph of a section ends in a section break instead of a plain ¶.
|
||||
if let Some(start) = section_break_after(doc, story, path) {
|
||||
let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) };
|
||||
section_break_mark(start, x0, x1, base, size, alpha, out);
|
||||
} else {
|
||||
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color });
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of section break that ends at body paragraph `path`: the start type of the section
|
||||
/// that follows it. `None` for any other paragraph, and for the document's last section.
|
||||
fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option<SectionStart> {
|
||||
if story != StoryRef::Body || path.0.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let i = *path.0.first()? as usize;
|
||||
match doc.body.get(i).map(|b| &**b) {
|
||||
Some(Block::Para(p)) if p.section.is_some() => {}
|
||||
_ => return None,
|
||||
}
|
||||
let secs = doc.sections();
|
||||
let k = secs.iter().position(|(end, _)| *end == i)?;
|
||||
secs.get(k + 1).map(|(_, s)| s.start)
|
||||
}
|
||||
|
||||
/// On-screen name of a section break.
|
||||
pub fn section_break_label(start: SectionStart) -> &'static str {
|
||||
match start {
|
||||
SectionStart::NextPage => "Section Break (Next Page)",
|
||||
SectionStart::Continuous => "Section Break (Continuous)",
|
||||
SectionStart::EvenPage => "Section Break (Even Page)",
|
||||
SectionStart::OddPage => "Section Break (Odd Page)",
|
||||
SectionStart::NextColumn => "Section Break (Next Column)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a [`Draw::MarkText`] label.
|
||||
pub fn mark_text_width(text: &str, size: f32) -> f32 {
|
||||
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let k = size / face.upem.max(1.0) as f32;
|
||||
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
|
||||
}
|
||||
|
||||
/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred
|
||||
/// in it (just the name when there is no room for the rule).
|
||||
fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec<Draw>) {
|
||||
let text = section_break_label(start);
|
||||
let lsize = (size * 0.8).clamp(6.0, 11.0);
|
||||
let w = mark_text_width(text, lsize);
|
||||
let gap = lsize * 0.4;
|
||||
let mid = base - size * 0.3;
|
||||
let color = Rgb(0x60, 0x60, 0x60);
|
||||
if x1 - x0 <= w + 4.0 * gap {
|
||||
out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() });
|
||||
return;
|
||||
}
|
||||
let tx = (x0 + x1 - w) / 2.0;
|
||||
for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] {
|
||||
for y in [mid - 1.2, mid + 1.2] {
|
||||
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha });
|
||||
}
|
||||
}
|
||||
out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() });
|
||||
}
|
||||
|
||||
/// An equation's glyphs and rules with its origin at (`x`, `base`).
|
||||
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
|
||||
for it in items {
|
||||
|
||||
@@ -373,6 +373,40 @@ impl DocLayout {
|
||||
None
|
||||
}
|
||||
|
||||
/// Column (block) selection: the piece of every line from the line holding `a` to the line
|
||||
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
|
||||
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
|
||||
/// pair. At most `max` lines.
|
||||
pub fn column_segments(&self, doc: &Document, a: &Pos, b: &Pos, left: f32, right: f32, page_hint: usize, max: usize) -> Vec<(Pos, Pos)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
let (left, right) = if left <= right { (left, right) } else { (right, left) };
|
||||
let mut out = Vec::new();
|
||||
if a.story != b.story || !left.is_finite() || !right.is_finite() {
|
||||
return out;
|
||||
}
|
||||
for path in doc.paths_between(a, b) {
|
||||
for (_, it) in self.pieces(a.story, &path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, .. } = it else { continue };
|
||||
let first = if path == a.path { para.line_of(a.off) } else { 0 };
|
||||
let last = if path == b.path { para.line_of(b.off) } else { usize::MAX };
|
||||
for li in *l0..*l1 {
|
||||
if li < first || li > last {
|
||||
continue;
|
||||
}
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
let o0 = para.off_at_x(li, left - x);
|
||||
let o1 = para.off_at_x(li, right - x);
|
||||
let (o0, o1) = if o0 <= o1 { (o0, o1) } else { (o1, o0) };
|
||||
let mk = |off| Pos { story: a.story, path: path.clone(), off };
|
||||
out.push((mk(o0), mk(o1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Highlight rectangles for the selection `a..b`, per page.
|
||||
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
|
||||
@@ -168,6 +168,14 @@ impl Line {
|
||||
None => self.xs.last().copied().unwrap_or(self.left),
|
||||
}
|
||||
}
|
||||
/// x of the line's visual end for the paragraph's direction, where its paragraph mark goes:
|
||||
/// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the
|
||||
/// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text
|
||||
/// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one).
|
||||
pub fn visual_end_x(&self) -> f32 {
|
||||
let end = self.end_x();
|
||||
if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) }
|
||||
}
|
||||
/// x where the hyphen of a hyphenated line (advance `adv`) is drawn.
|
||||
pub fn hyphen_x(&self, adv: f32) -> f32 {
|
||||
match self.vis.last() {
|
||||
@@ -248,8 +256,9 @@ pub struct ParaLayout {
|
||||
/// tracked deletions in the final text. Resolved as laid out, table formatting included.
|
||||
pub left_out: Vec<std::ops::Range<usize>>,
|
||||
/// The text drawn by clusters that stand for an object (field results, note numbers,
|
||||
/// equations) rather than for the paragraph's own text, by cluster index, sorted.
|
||||
pub shown: Vec<(usize, String)>,
|
||||
/// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of
|
||||
/// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters).
|
||||
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
|
||||
}
|
||||
|
||||
/// What a table style gives the text of a cell: its paragraph and run formatting, with the
|
||||
@@ -302,7 +311,7 @@ struct Builder<'a> {
|
||||
/// Bidi level per byte of the paragraph text (empty: all left to right).
|
||||
levels: Vec<u8>,
|
||||
/// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`).
|
||||
shown: Vec<(usize, String)>,
|
||||
shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
|
||||
}
|
||||
|
||||
/// What a piece of text is shaped as: one face, case, script formatting and direction.
|
||||
@@ -564,18 +573,28 @@ impl<'a> Builder<'a> {
|
||||
});
|
||||
return;
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster.
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's
|
||||
// text (a ligature's glyph takes the letters of the clusters after it that have none).
|
||||
let mut x = 0.0;
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for c in &added {
|
||||
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
g.dx += x;
|
||||
}
|
||||
x += c.adv;
|
||||
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
|
||||
if c.g1 <= c.g0 {
|
||||
let from = ranges.last().map(|r| r.start);
|
||||
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
|
||||
r.end = b;
|
||||
}
|
||||
}
|
||||
ranges.extend((c.g0..c.g1).map(|_| a..b));
|
||||
}
|
||||
let g0 = first.g0;
|
||||
let style = first.style;
|
||||
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
|
||||
self.shown.push((self.clusters.len(), text.to_string()));
|
||||
self.shown.push((self.clusters.len(), text.to_string(), ranges));
|
||||
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
|
||||
}
|
||||
}
|
||||
|
||||
+100
-20
@@ -4,7 +4,7 @@
|
||||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::TableStyleProps;
|
||||
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
@@ -40,8 +40,9 @@ struct Turned {
|
||||
across: f32,
|
||||
/// List counters and equation count before the cell, to lay it out again the same way.
|
||||
snap: (Counters, u32),
|
||||
/// Its formatting region (header row, total row, first column) and cell index in the row.
|
||||
region: (bool, bool, bool),
|
||||
/// Its formatting region (header row, total row, first column, last column, row band, column
|
||||
/// band) and cell index in the row.
|
||||
region: (bool, bool, bool, bool, bool, bool),
|
||||
ci: usize,
|
||||
/// Top margin plus top border band.
|
||||
top: f32,
|
||||
@@ -94,11 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
|
||||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
}
|
||||
|
||||
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
|
||||
/// total row, first column, last column, row band, column band), later regions winning. Row
|
||||
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
|
||||
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
|
||||
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
|
||||
let [header, total, first_col, last_col, band, col_band] = regions;
|
||||
let row = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: b.top,
|
||||
bottom: b.bottom,
|
||||
left: if first_g { b.left } else { b.inside_v },
|
||||
right: if last_g { b.right } else { b.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let col = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: if ri == 0 { b.top } else { b.between },
|
||||
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let mut out = Borders::default();
|
||||
for (on, b) in [
|
||||
(col_band, col(p.col_band_borders)),
|
||||
(band, row(p.band_borders)),
|
||||
(first_col, col(p.first_col_borders)),
|
||||
(last_col, col(p.last_col_borders)),
|
||||
(header, row(p.header_borders)),
|
||||
(total, row(p.total_borders)),
|
||||
] {
|
||||
if on && let Some(b) = b {
|
||||
out.overlay(&b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
|
||||
let style = table_style(ctx, t);
|
||||
let parts = style.as_ref().map(|s| &s.parts);
|
||||
// Cell text formatting per (header row, total row, first column) region, built once per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new();
|
||||
// Cell text formatting per region (header row, total row, first column, last column, row
|
||||
// band, column band), built once per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new();
|
||||
let ncols = t.cols().max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
@@ -152,6 +196,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
};
|
||||
let nrows = t.rows.len();
|
||||
let header_rows = t.props.look.header_row;
|
||||
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
|
||||
// First pass: lay out every cell's content.
|
||||
struct CellBox {
|
||||
items: Vec<Placed>,
|
||||
@@ -178,7 +223,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let mut row_insets = 0.0f32;
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
let x0 = colx.get(g).copied().unwrap_or(acc);
|
||||
@@ -186,15 +231,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
||||
let mut fill = cell.props.shading;
|
||||
let region = (is_header, is_total, t.props.look.first_column && g == 0);
|
||||
let first_col = t.props.look.first_column && g == 0;
|
||||
let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1;
|
||||
let first_cols = usize::from(t.props.look.first_column);
|
||||
let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2);
|
||||
let region = (is_header, is_total, first_col, last_col, band, col_band);
|
||||
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
|
||||
// column bands, row bands, first/last column, header/total row.
|
||||
if let Some(p) = parts {
|
||||
if is_header {
|
||||
fill = fill.or(p.header_fill);
|
||||
}
|
||||
if band && fill.is_none() {
|
||||
fill = p.band_fill;
|
||||
}
|
||||
fill = fill.or(p.fill);
|
||||
let row_fill = if is_header {
|
||||
p.header_fill
|
||||
} else if is_total {
|
||||
p.total_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let col_fill = if first_col {
|
||||
p.first_col_fill
|
||||
} else if last_col {
|
||||
p.last_col_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fill = fill
|
||||
.or(row_fill)
|
||||
.or(col_fill)
|
||||
.or(if band { p.band_fill } else { None })
|
||||
.or(if col_band { p.col_band_fill } else { None })
|
||||
.or(p.fill);
|
||||
}
|
||||
if let Some(st) = &style
|
||||
&& !cell_text.iter().any(|(r, _)| *r == region)
|
||||
@@ -202,9 +266,18 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
// The whole table's formatting, then column, then row conditional formatting
|
||||
// (later regions win, ECMA-376 §17.7.6).
|
||||
let mut chr = st.chr.clone();
|
||||
if region.2 {
|
||||
if col_band {
|
||||
chr.overlay(&st.parts.col_band_chr);
|
||||
}
|
||||
if band {
|
||||
chr.overlay(&st.parts.band_chr);
|
||||
}
|
||||
if first_col {
|
||||
chr.overlay(&st.parts.first_col_chr);
|
||||
}
|
||||
if last_col {
|
||||
chr.overlay(&st.parts.last_col_chr);
|
||||
}
|
||||
if is_header {
|
||||
chr.overlay(&st.parts.header_chr);
|
||||
} else if is_total {
|
||||
@@ -221,15 +294,22 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let edge =
|
||||
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
|
||||
let cb = cell.props.borders.unwrap_or_default();
|
||||
let (first_g, last_g) = (g == 0, g + span >= ncols);
|
||||
let rb = parts
|
||||
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
|
||||
.unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top, ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
|
||||
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
|
||||
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
|
||||
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
|
||||
left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v),
|
||||
right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v),
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
|
||||
if is_total
|
||||
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
|
||||
&& let Some(b) = parts.and_then(|p| p.total_border_top)
|
||||
{
|
||||
borders.top = Some(b);
|
||||
}
|
||||
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
|
||||
|
||||
@@ -260,10 +260,14 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
||||
#[test]
|
||||
fn fragmented_hidden_text_lays_out_like_the_visible_text() {
|
||||
// Visible words with a hidden multi-byte char after every visible one, formatted in alternating
|
||||
// runs (bold on and off) so no two left-out runs are adjacent or merge.
|
||||
// runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has
|
||||
// the same visible runs without the hidden chars, so both lay out the same widths whatever font
|
||||
// the default font resolves to.
|
||||
let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6);
|
||||
let mut d = Document::from_text("");
|
||||
d.settings.auto_hyphenation = true;
|
||||
let mut plain = Document::from_text("");
|
||||
plain.settings.auto_hyphenation = true;
|
||||
let mut text = String::new();
|
||||
let mut kept = Vec::new();
|
||||
for (i, c) in words.chars().enumerate() {
|
||||
@@ -276,10 +280,11 @@ fn fragmented_hidden_text_lays_out_like_the_visible_text() {
|
||||
d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
|
||||
let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() };
|
||||
d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap();
|
||||
let n = plain.para(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
plain.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
|
||||
}
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text);
|
||||
let mut plain = Document::from_text(&words);
|
||||
plain.settings.auto_hyphenation = true;
|
||||
assert_eq!(plain.para(StoryRef::Body, &Path::top(0)).unwrap().text, words);
|
||||
for doc in [&mut d, &mut plain] {
|
||||
doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap();
|
||||
}
|
||||
@@ -656,6 +661,20 @@ fn selection_rects_cover_range() {
|
||||
assert!(r[0].1.w > 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_segments_take_the_same_x_range_on_every_line() {
|
||||
let d = Document::from_text("abcdef\nabcdef\nab");
|
||||
let l = lay(&d);
|
||||
let left = l.caret(&Pos::body(0, 2)).unwrap().x;
|
||||
let right = l.caret(&Pos::body(0, 4)).unwrap().x;
|
||||
let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100);
|
||||
let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect();
|
||||
assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]);
|
||||
// Capped, and junk x gives nothing.
|
||||
assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2);
|
||||
assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_has_glyphs_and_marks() {
|
||||
let mut d = Document::from_text("Hello\tworld");
|
||||
@@ -684,6 +703,63 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
|
||||
assert_eq!(marks(false), [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_breaks_show_with_formatting_marks() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Two sections: "One" ends the first; the second starts as `start`.
|
||||
let doc = |start: SectionStart| {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d
|
||||
};
|
||||
let labels = |d: &Document, marks: bool| -> (Vec<String>, usize) {
|
||||
let l = lay(d);
|
||||
let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() });
|
||||
let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect();
|
||||
let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '¶', .. })).count();
|
||||
(texts, pilcrows)
|
||||
};
|
||||
let d = doc(SectionStart::Continuous);
|
||||
// The break replaces the first paragraph's ¶; the last paragraph keeps its own.
|
||||
assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1));
|
||||
assert_eq!(labels(&d, false), (vec![], 0));
|
||||
assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]);
|
||||
assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]);
|
||||
assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]);
|
||||
// A document with one section has no break.
|
||||
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
// logically last letters are at the wrong edge, but the ¶ still goes past every glyph.
|
||||
let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc");
|
||||
let at = Pos::body(1, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
|
||||
let l = lay(&d);
|
||||
// Leftmost and rightmost glyph edges (page x) of each paragraph's single line.
|
||||
let extents: Vec<(f32, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None })
|
||||
.map(|(pl, x)| {
|
||||
let ln = &pl.lines[0];
|
||||
let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?)));
|
||||
edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r)))
|
||||
})
|
||||
.collect();
|
||||
let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() })
|
||||
.into_iter()
|
||||
.filter_map(|i| if let display::Draw::Mark { ch: '¶', x, size, .. } = i { Some((x, size)) } else { None })
|
||||
.collect();
|
||||
assert_eq!((extents.len(), marks.len()), (2, 2));
|
||||
let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]);
|
||||
assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]);
|
||||
assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]);
|
||||
}
|
||||
|
||||
fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> {
|
||||
border_lines_in(d, &lay(d))
|
||||
}
|
||||
|
||||
@@ -303,24 +303,38 @@ fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
|
||||
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
|
||||
}
|
||||
|
||||
/// The glyph ids of each cluster, in order. A font may draw a letter as several glyphs (Noto
|
||||
/// Naskh Arabic draws ب as its dotless form plus the dot), so letters are compared by cluster.
|
||||
fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
|
||||
let mut out: Vec<(usize, Vec<u32>)> = Vec::new();
|
||||
for g in glyphs {
|
||||
match out.last_mut() {
|
||||
Some((c, ids)) if *c == g.cluster => ids.push(g.gid),
|
||||
_ => out.push((g.cluster, vec![g.gid])),
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(_, ids)| ids).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_letters_join_and_brackets_mirror() {
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let isolated = wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true);
|
||||
let joined = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
|
||||
assert_eq!(joined.len(), 3);
|
||||
assert!(joined.iter().all(|g| g.gid != 0), "the font has the letters");
|
||||
let isolated = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true));
|
||||
assert_eq!(isolated.len(), 1, "{isolated:?}");
|
||||
let shaped = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
|
||||
let joined = cluster_glyphs(&shaped);
|
||||
assert_eq!(joined.len(), 3, "one cluster per letter: {joined:?}");
|
||||
assert!(shaped.iter().all(|g| g.gid != 0), "the font has the letters");
|
||||
// Initial, medial and final forms differ from the isolated letter.
|
||||
assert!(joined.iter().any(|g| g.gid != isolated[0].gid), "cursive joining picks contextual forms");
|
||||
assert!(joined.iter().any(|g| *g != isolated[0]), "cursive joining picks contextual forms: {joined:?} {isolated:?}");
|
||||
// Clusters come back in logical order.
|
||||
assert!(joined.windows(2).all(|w| w[0].cluster <= w[1].cluster));
|
||||
assert!(shaped.windows(2).all(|w| w[0].cluster <= w[1].cluster));
|
||||
// ZWNJ (نیمفاصله) breaks the join: "میخواهم" keeps می separate.
|
||||
let with_zwnj = wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true);
|
||||
let first = with_zwnj.first().map(|g| g.gid);
|
||||
assert_eq!(first, Some(isolated[0].gid), "a letter before ZWNJ takes its isolated form");
|
||||
let with_zwnj = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true));
|
||||
assert_eq!(with_zwnj.first(), isolated.first(), "a letter before ZWNJ takes its isolated form");
|
||||
// In right-to-left text, "(" is drawn with the mirrored glyph.
|
||||
let open = wordcraft_fonts::shape_run(&face, "(", &[], |c| c, true);
|
||||
let close = wordcraft_fonts::shape_run(&face, ")", &[], |c| c, false);
|
||||
@@ -354,6 +368,10 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
|
||||
let s = "سلام WordCraft میخواهم";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
let l = lay(&d);
|
||||
let lam_alef_ligature = arabic_face().is_some_and(|f| {
|
||||
let g = wordcraft_fonts::shape_run(&f, "لا", &[], |c| c, true);
|
||||
!g.is_empty() && g.iter().all(|g| g.cluster == 0)
|
||||
});
|
||||
let mut all = String::new();
|
||||
for it in display::page_display(&d, &l.pages[0], &Default::default()) {
|
||||
if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it {
|
||||
@@ -361,8 +379,9 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
|
||||
for r in &ranges {
|
||||
assert!(text.get(r.clone()).is_some_and(|t| !t.is_empty()), "{r:?} in {text:?}");
|
||||
}
|
||||
// A ligature glyph (لا) shows both its letters (when a Persian font draws it).
|
||||
for r in ranges.iter().filter(|_| arabic_face().is_some()) {
|
||||
// A ligature glyph (لا) shows both its letters (when the Persian font draws one: some,
|
||||
// such as Noto Naskh Arabic, draw the lam and the alef as glyphs of their own).
|
||||
for r in ranges.iter().filter(|_| lam_alef_ligature) {
|
||||
if text.get(r.clone()) == Some("ل") {
|
||||
panic!("the lam of لا is drawn as one glyph with the alef: {text:?} {ranges:?}");
|
||||
}
|
||||
|
||||
@@ -650,7 +650,7 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Mark { .. } => {}
|
||||
Draw::Mark { .. } | Draw::MarkText { .. } => {}
|
||||
Draw::Turned { x, y, turn, items } => {
|
||||
if !ok(*x) || !ok(*y) {
|
||||
return;
|
||||
|
||||
@@ -539,6 +539,23 @@ fn field_results_are_extractable_text() {
|
||||
assert!(squash(&text).contains("See Section 3.01 for details."), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_result_ligatures_extract_all_their_letters() {
|
||||
// A field result is shaped as one cluster; a ligature in it (Carlito, the Calibri substitute,
|
||||
// joins "ti") used to get only its first letter, shifting the rest: "Sectio 3.01". Bundled
|
||||
// Source Sans 3 joins "fi" and "ffi", so this doesn't depend on the installed fonts.
|
||||
let font = CharProps { font: Some("Source Sans 3".into()), ..Default::default() };
|
||||
let result = "the first office";
|
||||
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
|
||||
assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures");
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("See here.", font.clone());
|
||||
p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false }, &font).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
|
||||
assert!(squash(&text).contains("See the first office here."), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bold_text_is_extracted_once() {
|
||||
// JetBrains Mono ships without a bold face, so its bold is filled and stroked: the text
|
||||
|
||||
@@ -382,6 +382,25 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
}
|
||||
Draw::MarkText { x, baseline, size, text } => {
|
||||
let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
ctx.set_paint(color(opts.ink(opts.mark_color), 0.9));
|
||||
let mut gx = *x as f64;
|
||||
for ch in text.chars() {
|
||||
let mut face = base_face;
|
||||
if face.glyph_for(ch) == 0
|
||||
&& let Some(f) = FontDb::global().fallback_for(ch, face.id())
|
||||
{
|
||||
face = wordcraft_fonts::FaceRef::of(&f);
|
||||
}
|
||||
let gid = face.glyph_for(ch);
|
||||
let k = *size as f64 / face.upem.max(1.0);
|
||||
let o = FontDb::global().outline(&face, gid);
|
||||
ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
gx += face.advance(gid) * k;
|
||||
}
|
||||
}
|
||||
Draw::Image { rect, media, crop, alpha } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
if !r.overlaps(*visible) {
|
||||
|
||||
@@ -270,17 +270,35 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.columns(2, |cols| {
|
||||
let ui = &mut cols[0];
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut changed = false;
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
|
||||
ui.label(tl!(l));
|
||||
changed |= ui.text_edit_singleline(v).lost_focus();
|
||||
let label = ui.label(tl!(l));
|
||||
let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id);
|
||||
if r.changed() {
|
||||
edited = Some(l);
|
||||
}
|
||||
if r.has_focus() {
|
||||
focused = Some(l);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category}));
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
}
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
@@ -445,4 +463,66 @@ mod tests {
|
||||
assert!(h.query_by_label("Report").is_some(), "New shows every template");
|
||||
assert!(h.query_by_label("Recent").is_none(), "New has no Recent list");
|
||||
}
|
||||
|
||||
/// #260: File › Info's Title and Author fields dropped every keystroke (they edited a copy
|
||||
/// re-read each frame and only wrote it back when the field lost focus), so a save had no
|
||||
/// title or author. Typing now lands in the document as it happens, one undo step per field,
|
||||
/// and the browser's Save downloads it with the save stamps advanced (#262).
|
||||
#[test]
|
||||
fn info_fields_keep_typed_properties_through_a_web_save() {
|
||||
use egui::accesskit::Role;
|
||||
let got: std::rc::Rc<std::cell::RefCell<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let sink = got.clone();
|
||||
let services = Services {
|
||||
download: Some(Box::new(move |n: &str, b: &[u8]| {
|
||||
sink.borrow_mut().push((n.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("XYZ")), services);
|
||||
a.session.author = "GAMMA".into();
|
||||
a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
for (field, text) in [("Title", "ALPHA"), ("Author", "BETA")] {
|
||||
h.get_by_role_and_label(Role::TextInput, field).click();
|
||||
h.run_steps(2);
|
||||
for c in text.chars() {
|
||||
h.get_by_role_and_label(Role::TextInput, field).type_text(&c.to_string());
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, field).value().as_deref(), Some(text), "{field} shows what was typed");
|
||||
}
|
||||
let core = h.state().session.doc.core.clone();
|
||||
assert_eq!((core.title.as_str(), core.creator.as_str()), ("ALPHA", "BETA"));
|
||||
assert!(h.state().session.dirty, "the property edits are unsaved changes");
|
||||
|
||||
// Save (web: a download) writes them into docProps/core.xml, with fresh save stamps.
|
||||
let a = h.state_mut();
|
||||
let created = a.session.doc.core.created.clone();
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
let (name, bytes) = got.borrow_mut().pop().unwrap();
|
||||
let back = wordcraft_engine::io::open_bytes(&name, &bytes).unwrap();
|
||||
assert_eq!((back.core.title.as_str(), back.core.creator.as_str()), ("ALPHA", "BETA"));
|
||||
assert_eq!(back.core.last_modified_by, "GAMMA");
|
||||
assert_eq!(back.core.revision, core.revision + 2, "each save advances the revision");
|
||||
assert!(created.is_empty() && !back.core.created.is_empty(), "the first save sets the creation time");
|
||||
assert!(!back.core.modified.is_empty());
|
||||
assert!(!a.session.dirty);
|
||||
|
||||
// Each field's typing is one undo step.
|
||||
a.session.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((a.session.doc.core.title.as_str(), a.session.doc.core.creator.as_str()), ("ALPHA", ""));
|
||||
a.session.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(a.session.doc.core.title, "");
|
||||
}
|
||||
}
|
||||
|
||||
+192
-13
@@ -23,6 +23,8 @@ pub struct CanvasState {
|
||||
pub scroll_to_caret: bool,
|
||||
pub caret_visible_since: f64,
|
||||
dragging: bool,
|
||||
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
|
||||
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
|
||||
pub last_highlight: String,
|
||||
pub last_font_color: String,
|
||||
pub last_shading: String,
|
||||
@@ -44,6 +46,11 @@ pub struct CanvasState {
|
||||
pub mini_anchor: Option<Rect>,
|
||||
/// A picture, shape or text box being dragged by its frame.
|
||||
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
|
||||
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
|
||||
pub(crate) wheel: crate::scroll::CanvasScroll,
|
||||
/// The scroll offset and its maximum at the end of last frame.
|
||||
pub(crate) scroll_offset: egui::Vec2,
|
||||
scroll_max: egui::Vec2,
|
||||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||||
pub cols: usize,
|
||||
}
|
||||
@@ -62,6 +69,7 @@ impl Default for CanvasState {
|
||||
scroll_to_caret: true,
|
||||
caret_visible_since: 0.0,
|
||||
dragging: false,
|
||||
column_drag: None,
|
||||
last_highlight: "yellow".into(),
|
||||
last_font_color: "C00000".into(),
|
||||
last_shading: "FFF2CC".into(),
|
||||
@@ -78,6 +86,9 @@ impl Default for CanvasState {
|
||||
obj_drag: None,
|
||||
context_menu_open: false,
|
||||
mini_anchor: None,
|
||||
wheel: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
cols: 1,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +205,18 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
|
||||
Geometry { rects, size, scale, cols }
|
||||
}
|
||||
|
||||
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
|
||||
/// equal to what is shown, as Word does. Zoom In/Out, the Zoom dialog and `view.state` then start
|
||||
/// from the visible zoom; before, they stepped from the stale manual zoom, so Zoom In from a 163%
|
||||
/// Page Width jumped to 110% (issue #67).
|
||||
pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
|
||||
let v = &mut app.session.view;
|
||||
if v.fit.is_empty() || v.read_mode || v.mode != wordcraft_layout::ViewMode::Print || !scale.is_finite() {
|
||||
return;
|
||||
}
|
||||
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's content for the texture cache.
|
||||
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -236,6 +259,43 @@ fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
|
||||
if page.w > 0.0 { rect.width() / page.w } else { fallback }
|
||||
}
|
||||
|
||||
/// Spacing of the View › Gridlines drawing grid: 0.5 cm, in points.
|
||||
const DRAWING_GRID_PT: f32 = 72.0 * 0.5 / 2.54;
|
||||
/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped.
|
||||
const DRAWING_GRID_MIN_PX: f32 = 4.0;
|
||||
/// Most grid lines drawn each way on one page (page sizes come from files).
|
||||
const DRAWING_GRID_MAX_LINES: usize = 2000;
|
||||
|
||||
/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each
|
||||
/// vertical line and the y of each horizontal line, starting at the margins and `step` apart,
|
||||
/// never outside the margins. Drawn on screen only — never printed or exported.
|
||||
fn drawing_grid(body: wordcraft_geom::Rect, step: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
let axis = |start: f32, len: f32| -> Vec<f32> {
|
||||
if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let end = start + len + 0.01;
|
||||
(0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect()
|
||||
};
|
||||
(axis(body.x, body.w), axis(body.y, body.h))
|
||||
}
|
||||
|
||||
/// The drawing grid's spacing in points at a screen scale: the default 0.5 cm, doubled until the
|
||||
/// lines are at least [`DRAWING_GRID_MIN_PX`] apart on screen.
|
||||
fn drawing_grid_step(scale: f32) -> f32 {
|
||||
let mut step = DRAWING_GRID_PT;
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return step;
|
||||
}
|
||||
for _ in 0..16 {
|
||||
if step * scale >= DRAWING_GRID_MIN_PX {
|
||||
break;
|
||||
}
|
||||
step *= 2.0;
|
||||
}
|
||||
step
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let layout = app.session.layout();
|
||||
@@ -262,6 +322,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
||||
app.canvas.scale = geo.scale;
|
||||
sync_fit_zoom(app, geo.scale);
|
||||
// Zooming or resizing reflowed the pages into a different number of columns: the caret's page
|
||||
// moved, so bring it back into view rather than leave the reader somewhere else.
|
||||
if geo.cols != app.canvas.cols {
|
||||
@@ -288,7 +349,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.scroll_to_caret = false;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| {
|
||||
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
|
||||
// momentum, wheel notches eased in. Applied before drawing, so it shows this frame.
|
||||
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
|
||||
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
|
||||
let mut scroll_area = egui::ScrollArea::both()
|
||||
.id_salt("canvas_scroll")
|
||||
.auto_shrink([false, false])
|
||||
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
|
||||
if delta != egui::Vec2::ZERO {
|
||||
let before = app.canvas.scroll_offset;
|
||||
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
|
||||
if after == before {
|
||||
app.canvas.wheel.hit_edge();
|
||||
}
|
||||
scroll_area = scroll_area.scroll_offset(after);
|
||||
}
|
||||
if app.canvas.wheel.is_animating() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
|
||||
origin = ui.min_rect().min - viewport.min.to_vec2();
|
||||
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
|
||||
let resp = ui.allocate_rect(content, Sense::click_and_drag());
|
||||
@@ -365,8 +447,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table gridlines.
|
||||
if app.session.view.gridlines {
|
||||
// View › Gridlines: the drawing grid over the text area (Print Layout, on screen only).
|
||||
if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode {
|
||||
let (xs, ys) = drawing_grid(page.body, drawing_grid_step(page_scale));
|
||||
let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22));
|
||||
let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale);
|
||||
let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale);
|
||||
for x in xs {
|
||||
let sx = visual_sr.min.x + x * page_scale;
|
||||
painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke);
|
||||
}
|
||||
for y in ys {
|
||||
let sy = visual_sr.min.y + y * page_scale;
|
||||
painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke);
|
||||
}
|
||||
}
|
||||
// Table Layout › View Gridlines: table cell outlines (on screen only).
|
||||
if app.session.view.table_gridlines {
|
||||
for it in &page.items {
|
||||
if let Placed::Cell { rect, .. } = it {
|
||||
let r = Rect::from_min_size(
|
||||
@@ -386,18 +483,35 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.textures.retain(|k, _| *k < n);
|
||||
app.canvas.page_rects = rects.clone();
|
||||
balloons(app, ui, &painter, &rects, &layout, geo.scale);
|
||||
// Selection (a selected object shows its frame instead).
|
||||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let mut first: Option<Rect> = None;
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) {
|
||||
// Find › Reading Highlight.
|
||||
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
|
||||
for (a, b) in &marks {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
let hr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(hr, 0.0, t.find_highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Selection (a selected object shows its frame instead).
|
||||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||||
// A column selection highlights each row's piece.
|
||||
let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() {
|
||||
Some(segs) => segs.to_vec(),
|
||||
None => vec![app.session.sel.ordered()],
|
||||
};
|
||||
let mut first: Option<Rect> = None;
|
||||
for (a, b) in &pieces {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,6 +605,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
(resp, rects)
|
||||
});
|
||||
app.canvas.scroll_offset = out.state.offset;
|
||||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||||
let (resp, rects) = out.inner;
|
||||
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
|
||||
if resp.clicked() || resp.drag_started() || app.canvas.want_focus {
|
||||
@@ -757,6 +873,15 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
// Alt+drag selects a column (block) of text.
|
||||
if mods.alt && !mods.shift && !mods.command {
|
||||
let _ = app.run("caret.set", json!({"pos": pj}));
|
||||
app.session.page_hint = page;
|
||||
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
}
|
||||
app.canvas.column_drag = None;
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
if mods.command && !mods.shift {
|
||||
let _ = app.run("select.sentence", json!({}));
|
||||
@@ -764,6 +889,18 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up.
|
||||
} else if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some((from, last)) = app.canvas.column_drag
|
||||
{
|
||||
if last != (page, x, y) {
|
||||
app.canvas.column_drag = Some((from, (page, x, y)));
|
||||
let story = app.session.sel.focus.story;
|
||||
let _ = app.run(
|
||||
"select.column",
|
||||
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
|
||||
);
|
||||
}
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& pos != app.session.sel.focus
|
||||
@@ -775,6 +912,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
// Auto-scroll near the edges is handled by egui's scroll area drag.
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
|
||||
app.canvas.dragging = false;
|
||||
app.canvas.column_drag = None;
|
||||
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
|
||||
let _ = app.run("edit.pasteFormat", json!({}));
|
||||
}
|
||||
@@ -1240,6 +1378,47 @@ mod tests {
|
||||
assert_eq!(visual.size(), vec2(80.5, 120.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_grid_starts_at_the_margins_and_stays_inside_them() {
|
||||
// Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72).
|
||||
let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0);
|
||||
let (xs, ys) = drawing_grid(body, DRAWING_GRID_PT);
|
||||
assert_eq!(xs.first().copied(), Some(72.0));
|
||||
assert_eq!(ys.first().copied(), Some(72.0));
|
||||
assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}");
|
||||
assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}");
|
||||
// 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45.
|
||||
assert_eq!(xs.len(), 34);
|
||||
assert_eq!(ys.len(), 46);
|
||||
for w in xs.windows(2).chain(ys.windows(2)) {
|
||||
assert!((w[1] - w[0] - DRAWING_GRID_PT).abs() < 0.01, "{w:?}");
|
||||
}
|
||||
// A grid line that lands on the margin is kept.
|
||||
let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), 25.0);
|
||||
assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_grid_survives_hostile_sizes() {
|
||||
let r = wordcraft_geom::Rect::new;
|
||||
for (body, step) in [
|
||||
(r(72.0, 72.0, 468.0, 648.0), 0.0),
|
||||
(r(72.0, 72.0, 468.0, 648.0), -5.0),
|
||||
(r(72.0, 72.0, 468.0, 648.0), f32::NAN),
|
||||
(wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, DRAWING_GRID_PT),
|
||||
] {
|
||||
let (xs, ys) = drawing_grid(body, step);
|
||||
assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}");
|
||||
}
|
||||
let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), DRAWING_GRID_PT);
|
||||
assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES));
|
||||
// Zoomed far out, lines thin out instead of filling the page.
|
||||
assert_eq!(drawing_grid_step(PX_PER_PT), DRAWING_GRID_PT);
|
||||
assert!(drawing_grid_step(0.1) * 0.1 >= DRAWING_GRID_MIN_PX);
|
||||
assert_eq!(drawing_grid_step(0.0), DRAWING_GRID_PT);
|
||||
assert_eq!(drawing_grid_step(f32::NAN), DRAWING_GRID_PT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() {
|
||||
let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, Command search, About, Save
|
||||
//! Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules,
|
||||
//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's
|
||||
//! result), so agents get the same result without the dialog.
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Command
|
||||
//! search, Paste Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge
|
||||
//! Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by
|
||||
//! running a command (or shows one's result), so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
@@ -112,9 +112,32 @@ pub enum Dialog {
|
||||
alphabetical: bool,
|
||||
selected: String,
|
||||
},
|
||||
/// Table Design › New / Modify Table Style: one formatting region at a time.
|
||||
TableStyle {
|
||||
/// The style being modified; `None` creates one.
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
/// Style id.
|
||||
based_on: String,
|
||||
/// Index into `regions` (see [`REGION_LABELS`]).
|
||||
region: usize,
|
||||
regions: Box<[TableRegion; 7]>,
|
||||
/// What the regions showed when the dialog opened (or the base style changed): only
|
||||
/// changes are sent, so everything else stays inherited.
|
||||
#[serde(skip)]
|
||||
basis: Box<[TableRegion; 7]>,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
},
|
||||
/// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
|
||||
/// of the serialized dialog state (it can be megabytes).
|
||||
PasteSpecial {
|
||||
formats: Vec<String>,
|
||||
choice: usize,
|
||||
#[serde(skip)]
|
||||
payload: Value,
|
||||
},
|
||||
/// Tabs: 0 About, 1 Contributors, 2 Models.
|
||||
About {
|
||||
tab: u8,
|
||||
@@ -222,6 +245,215 @@ pub fn recipient_table(fields: &[String], rows: &[Vec<String>]) -> Result<Value,
|
||||
Ok(json!({"fields": fields, "rows": rows}))
|
||||
}
|
||||
|
||||
/// One table style region in the Table Style dialog: colours are hex, empty for none.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableRegion {
|
||||
pub borders: bool,
|
||||
pub fill: String,
|
||||
pub bold: bool,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
|
||||
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
|
||||
|
||||
/// The regions of table style `id` as resolved through its based-on chain.
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
|
||||
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
|
||||
let p = &st.parts;
|
||||
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
|
||||
let text = |c: &wordcraft_doc::CharProps| match c.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
|
||||
// A region's text: the whole table's formatting with the region's over it.
|
||||
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
|
||||
let mut c = st.chr.clone();
|
||||
c.overlay(chr);
|
||||
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
|
||||
};
|
||||
let none = wordcraft_doc::CharProps::default();
|
||||
Box::new([
|
||||
region(p.borders, p.fill, &none),
|
||||
region(p.header_borders, p.header_fill, &p.header_chr),
|
||||
region(p.total_borders, p.total_fill, &p.total_chr),
|
||||
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
|
||||
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
|
||||
region(p.band_borders, p.band_fill, &p.band_chr),
|
||||
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
|
||||
])
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
|
||||
/// only what changed from `basis`, so the rest stays inherited.
|
||||
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
|
||||
let mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
if let Some(id) = id {
|
||||
v["style"] = json!(id);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
|
||||
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
|
||||
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
const TEXT: [[&str; COLS]; ROWS] = [
|
||||
["", "Mon", "Tue", "Wed", "Sum"],
|
||||
["North", "4", "7", "2", "13"],
|
||||
["South", "6", "1", "5", "12"],
|
||||
["West", "3", "8", "4", "15"],
|
||||
["Total", "13", "16", "11", "40"],
|
||||
];
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
// The page is white whatever the interface theme.
|
||||
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let Some(st) = style else { return };
|
||||
let p = &st.parts;
|
||||
let table = rect.shrink2(vec2(14.0, 10.0));
|
||||
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
|
||||
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
|
||||
let tb = p.borders.unwrap_or_default();
|
||||
for (ri, row) in TEXT.iter().enumerate() {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == ROWS;
|
||||
let band = look.banded_rows
|
||||
&& !header
|
||||
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
|
||||
for (ci, txt) in row.iter().enumerate() {
|
||||
let first = look.first_column && ci == 0;
|
||||
let last = look.last_column && ci + 1 == COLS;
|
||||
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
|
||||
// Regions from lowest to highest priority, as layout applies them.
|
||||
let regions = [
|
||||
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
|
||||
(band, p.band_fill, &p.band_chr, p.band_borders),
|
||||
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
|
||||
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
|
||||
(header, p.header_fill, &p.header_chr, p.header_borders),
|
||||
(total, p.total_fill, &p.total_chr, p.total_borders),
|
||||
];
|
||||
let mut fill = p.fill;
|
||||
let mut chr = st.chr.clone();
|
||||
let mut b = Borders {
|
||||
top: if ri == 0 { tb.top } else { tb.between },
|
||||
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
|
||||
left: if ci == 0 { tb.left } else { tb.inside_v },
|
||||
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
for (on, f, c, rb) in regions {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
fill = f.or(fill);
|
||||
chr.overlay(c);
|
||||
if let Some(rb) = rb {
|
||||
b.overlay(&Borders { between: None, inside_v: None, ..rb });
|
||||
}
|
||||
}
|
||||
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
|
||||
b.top = p.total_border_top.or(b.top);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
painter.rect_filled(cell, 0.0, rgb(f));
|
||||
}
|
||||
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
|
||||
if let Some(e) = e.filter(Border::is_visible) {
|
||||
let color = e.color.map_or(egui::Color32::BLACK, rgb);
|
||||
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
|
||||
}
|
||||
};
|
||||
edge(cell.left_top(), cell.right_top(), b.top);
|
||||
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
|
||||
edge(cell.left_top(), cell.left_bottom(), b.left);
|
||||
edge(cell.right_top(), cell.right_bottom(), b.right);
|
||||
let color = match chr.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
|
||||
_ => egui::Color32::BLACK,
|
||||
};
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
let pos = cell.left_center() + vec2(4.0, 0.0);
|
||||
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
|
||||
if chr.bold.unwrap_or(false) {
|
||||
// A second pass a hair to the right reads as bold at this size.
|
||||
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of the table at the caret, when it is a table style.
|
||||
fn current_table_style(app: &WordApp) -> Option<String> {
|
||||
let s = &app.session;
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone())
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region.
|
||||
fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
|
||||
let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) };
|
||||
let mut v = json!({});
|
||||
if r.borders != basis.borders {
|
||||
v["borders"] = json!(r.borders);
|
||||
}
|
||||
if r.fill != basis.fill {
|
||||
v["fill"] = color(&r.fill);
|
||||
}
|
||||
if r.bold != basis.bold {
|
||||
v["bold"] = json!(r.bold);
|
||||
}
|
||||
if r.color != basis.color {
|
||||
v["color"] = color(&r.color);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
|
||||
fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
match c {
|
||||
Some(c) => {
|
||||
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
|
||||
}
|
||||
None => {
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
|
||||
ui.menu_button(label, |ui| {
|
||||
if ui.button(tl!("No Color")).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
|
||||
*value = hex;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -240,7 +472,10 @@ impl Dialog {
|
||||
Dialog::NewStyle { .. } => "newStyle",
|
||||
Dialog::ModifyStyle { .. } => "modifyStyle",
|
||||
Dialog::ManageStyles { .. } => "manageStyles",
|
||||
Dialog::TableStyle { id: None, .. } => "newTableStyle",
|
||||
Dialog::TableStyle { .. } => "modifyTableStyle",
|
||||
Dialog::Commands { .. } => "commands",
|
||||
Dialog::PasteSpecial { .. } => "pasteSpecial",
|
||||
Dialog::About { .. } => "about",
|
||||
Dialog::SaveChanges { .. } => "saveChanges",
|
||||
Dialog::RecipientList { .. } => "recipientList",
|
||||
@@ -340,7 +575,22 @@ impl Dialog {
|
||||
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
|
||||
"manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
|
||||
"newTableStyle" => {
|
||||
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
|
||||
let styles = &app.session.doc.styles;
|
||||
let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default();
|
||||
let regions = table_regions(app, &based_on);
|
||||
Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"modifyTableStyle" => {
|
||||
let id = current_table_style(app)?;
|
||||
let st = app.session.doc.styles.get(&id)?;
|
||||
let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default());
|
||||
let regions = table_regions(app, &id);
|
||||
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
|
||||
"about" => Dialog::About { tab: 0 },
|
||||
"contributors" => Dialog::About { tab: 1 },
|
||||
"models" => Dialog::About { tab: 2 },
|
||||
@@ -379,6 +629,18 @@ impl Dialog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy).
|
||||
pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog {
|
||||
let formats: Vec<String> = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect();
|
||||
let mut kept = json!({});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = payload.get(k).and_then(Value::as_str) {
|
||||
kept[k] = json!(t);
|
||||
}
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice: 0, payload: kept }
|
||||
}
|
||||
|
||||
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
|
||||
let st = app.session.doc.styles.get(id)?;
|
||||
let rc = app.session.doc.styles.resolve_char(
|
||||
@@ -464,7 +726,10 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::NewStyle { .. } => "Create New Style",
|
||||
Dialog::ModifyStyle { .. } => "Modify Style",
|
||||
Dialog::ManageStyles { .. } => "Manage Styles",
|
||||
Dialog::TableStyle { id: None, .. } => "New Table Style",
|
||||
Dialog::TableStyle { .. } => "Modify Table Style",
|
||||
Dialog::Commands { .. } => "Search Commands",
|
||||
Dialog::PasteSpecial { .. } => "Paste Special",
|
||||
Dialog::About { .. } => "About WordCraft",
|
||||
Dialog::SaveChanges { .. } => "WordCraft",
|
||||
Dialog::RecipientList { .. } => "Recipient List",
|
||||
@@ -662,10 +927,24 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.checkbox(whole_word, tl!("Whole words"));
|
||||
ui.checkbox(regex, tl!("Wildcards (regex)"));
|
||||
});
|
||||
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
|
||||
if !*replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
let lit = app.session.find.highlight;
|
||||
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
|
||||
let mut v = opts.clone();
|
||||
v["highlight"] = json!(!lit);
|
||||
match app.run("edit.advancedFind", v) {
|
||||
Ok(r) if !lit => *message = crate::i18n::fmt(tl!("{count} items highlighted"), &[("count", &r["count"].to_string())]),
|
||||
Ok(_) => message.clear(),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).weak());
|
||||
}
|
||||
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if *replace_mode {
|
||||
@@ -805,7 +1084,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.radio_value(percent, p, format!("{p}%"));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%"));
|
||||
// Logarithmic like the status bar slider, and wide enough to aim: the default 100 pt
|
||||
// linear slider jumped from 100% to 480% within a short drag (issue #67).
|
||||
ui.spacing_mut().slider_width = 220.0;
|
||||
ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%"));
|
||||
let mut fit = None;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Page width")).clicked() {
|
||||
@@ -904,6 +1186,97 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected),
|
||||
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
|
||||
let styles: Vec<(String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref())
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
|
||||
let mut rebased = false;
|
||||
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Style based on:"));
|
||||
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
|
||||
for (sid, sname) in &styles {
|
||||
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
|
||||
*based_on = sid.clone();
|
||||
rebased = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Apply formatting to:"));
|
||||
egui::ComboBox::from_id_salt("tstyle_region")
|
||||
.width(200.0)
|
||||
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, n) in REGION_LABELS.iter().enumerate() {
|
||||
ui.selectable_value(region, i, tl!(n));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
// A new style shows its new base's formatting; a modified one keeps its own.
|
||||
if rebased && id.is_none() {
|
||||
*regions = table_regions(app, based_on);
|
||||
*basis = regions.clone();
|
||||
}
|
||||
ui.separator();
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(r) = regions.get_mut(*region) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut r.borders, tl!("Borders"));
|
||||
ui.checkbox(&mut r.bold, tl!("Bold"));
|
||||
});
|
||||
egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Fill color:"));
|
||||
color_menu(ui, &theme, &mut r.fill);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Font color:"));
|
||||
color_menu(ui, &theme, &mut r.color);
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
// Live preview: the style as OK would leave it, on the current table's options with
|
||||
// the region being edited turned on.
|
||||
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
|
||||
let mut look = app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
|
||||
.unwrap_or_default();
|
||||
match *region {
|
||||
1 => look.header_row = true,
|
||||
2 => look.total_row = true,
|
||||
3 => look.first_column = true,
|
||||
4 => look.last_column = true,
|
||||
5 => look.banded_rows = true,
|
||||
6 => look.banded_columns = true,
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
|
||||
if let Err(e) = app.run(cmd, v) {
|
||||
app.status(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Commands { query } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0));
|
||||
r.request_focus();
|
||||
@@ -933,6 +1306,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice, payload } => {
|
||||
ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5)));
|
||||
if formats.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak());
|
||||
}
|
||||
for (i, f) in formats.iter().enumerate() {
|
||||
let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str());
|
||||
ui.radio_value(choice, i, tl!(label));
|
||||
}
|
||||
if let Some(f) = formats.get(*choice) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5)));
|
||||
let hint = match f.as_str() {
|
||||
"formatted" => "Keeps the fonts and formatting of the copied text.",
|
||||
"rtf" => "Reads the clipboard's rich text and keeps its formatting.",
|
||||
"html" => "Reads the clipboard's web content and keeps its formatting.",
|
||||
_ => "Drops all formatting and pastes plain text.",
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!(hint)).weak());
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok && let Some(f) = formats.get(*choice) {
|
||||
let mut params = payload.clone();
|
||||
params["as"] = json!(f);
|
||||
let _ = app.run("edit.pasteSpecial", params);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::About { tab } => {
|
||||
ui.set_width(660.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
Address\nBlock Bloque de\ndirección
|
||||
Adjust Ajustar
|
||||
Advanced Avanzado
|
||||
Advanced Find Búsqueda avanzada
|
||||
Advanced Find… Búsqueda avanzada…
|
||||
After: Después:
|
||||
Agents Agentes
|
||||
Align Alinear
|
||||
@@ -109,6 +111,7 @@
|
||||
Character Scale Escala del carácter
|
||||
Character Shading Sombreado de carácter
|
||||
Character Spacing Espaciado de caracteres
|
||||
Characters Caracteres
|
||||
Characters (no spaces) Caracteres (sin espacios)
|
||||
Characters (with spaces) Caracteres (con espacios)
|
||||
Chart Gráfico
|
||||
@@ -120,8 +123,21 @@
|
||||
Citation Style Estilo de cita
|
||||
Citations & Bibliography Citas y bibliografía
|
||||
Clear Borrar
|
||||
Clear All Borrar todo
|
||||
Clear All Formatting Borrar todo el formato
|
||||
Clear Character Formatting Borrar formato de carácter
|
||||
Clear Character Style Borrar estilo de carácter
|
||||
Clear Formatting Level Borrar nivel de formato
|
||||
Clear Paragraph Formatting Borrar formato de párrafo
|
||||
Clipboard Portapapeles
|
||||
Clipboard Pane Panel del portapapeles
|
||||
Clipboard Items Elementos del portapapeles
|
||||
Paste Clipboard Item Pegar elemento del portapapeles
|
||||
Paste All Pegar todo
|
||||
Delete Clipboard Item Eliminar elemento del portapapeles
|
||||
Click an item to paste it. Haz clic en un elemento para pegarlo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí.
|
||||
(picture or object) (imagen u objeto)
|
||||
Close Cerrar
|
||||
Close Header and Footer Cerrar encabezado y pie de página
|
||||
Collapse Selection Contraer selección
|
||||
@@ -187,6 +203,7 @@
|
||||
Dictate Dictar
|
||||
Different First Page Primera página diferente
|
||||
Different Odd & Even Pages Páginas pares e impares diferentes
|
||||
Direct formatting Formato directo
|
||||
Discord Discord
|
||||
Discord community Comunidad de Discord
|
||||
Display Mostrar
|
||||
@@ -214,6 +231,7 @@
|
||||
Drawing Tools Herramientas de dibujo
|
||||
Drawing\nCanvas Lienzo de\ndibujo
|
||||
Drop Cap Letra capital
|
||||
Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato.
|
||||
Edit Footer Editar pie de página
|
||||
Edit Header Editar encabezado
|
||||
Edit Recipient List Editar lista de destinatarios
|
||||
@@ -269,11 +287,13 @@
|
||||
Format Page Numbers Formato de números de página
|
||||
Format Page Numbers… Formato de números de página…
|
||||
Format Painter Pincel de formato
|
||||
Formatted Text Texto con formato
|
||||
Formatting at Selection Formato en la selección
|
||||
Formula Fórmula
|
||||
General General
|
||||
Go To Ir a
|
||||
Go To (⌘⌥G) Ir a (⌘⌥G)
|
||||
Go To… Ir a…
|
||||
Go to Ir a
|
||||
Grab bag Bolsa de datos adicionales
|
||||
Greeting Line Línea de saludo
|
||||
@@ -281,6 +301,7 @@
|
||||
Gridlines Líneas de cuadrícula
|
||||
Group Agrupar
|
||||
Grow Font 1 Point Aumentar fuente 1 punto
|
||||
HTML Format Formato HTML
|
||||
Hanging Indent Sangría francesa
|
||||
Header Encabezado
|
||||
Header & Footer Encabezado y pie de página
|
||||
@@ -340,6 +361,7 @@
|
||||
Insert\nFootnote Insertar nota\nal pie
|
||||
Inside Borders Bordes interiores
|
||||
Inspect Document Inspeccionar documento
|
||||
Inspect Styles at Selection Inspeccionar estilos de la selección
|
||||
Interface language: Idioma de la interfaz:
|
||||
Italic Cursiva
|
||||
Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord
|
||||
@@ -350,6 +372,7 @@
|
||||
Keep lines together Mantener líneas juntas
|
||||
Keep with Next Mantener con el siguiente
|
||||
Keep with next Mantener con el siguiente
|
||||
Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado.
|
||||
Labels Etiquetas
|
||||
Landscape Horizontal
|
||||
Language Idioma
|
||||
@@ -494,6 +517,9 @@
|
||||
Paste Pegar
|
||||
Paste (⌘V) Pegar (⌘V)
|
||||
Paste Formatting Pegar formato
|
||||
Paste Special Pegado especial
|
||||
Paste Special… Pegado especial…
|
||||
Paste as: Pegar como:
|
||||
Paste: Keep Text Only Pegar: mantener solo el texto
|
||||
Paste: Merge Formatting Pegar: combinar formato
|
||||
Pen Pluma
|
||||
@@ -527,6 +553,9 @@
|
||||
Read Mode Modo lectura
|
||||
Read\nAloud Leer en\nvoz alta
|
||||
Read\nMode Modo\nlectura
|
||||
Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato.
|
||||
Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato.
|
||||
Reading Highlight Resaltado de lectura
|
||||
Recent Recientes
|
||||
Record Macro Grabar macro
|
||||
Recover Unsaved Documents Recuperar documentos sin guardar
|
||||
@@ -557,6 +586,7 @@
|
||||
Research Investigación
|
||||
Researcher Investigador
|
||||
Reset Picture Restablecer imagen
|
||||
Reset to Normal Restablecer a Normal
|
||||
Resolve Resolver
|
||||
Resolved Resuelto
|
||||
Restart Each Page Reiniciar en cada página
|
||||
@@ -564,12 +594,14 @@
|
||||
Restart at 1 Reiniciar en 1
|
||||
Restrict Editing Restringir edición
|
||||
Restrict\nEditing Restringir\nedición
|
||||
Result Resultado
|
||||
Results Resultados
|
||||
Reverse the order Invertir el orden
|
||||
Reviewing Revisión
|
||||
Reviewing Pane Panel de revisión
|
||||
Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias
|
||||
Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf)
|
||||
Rich Text Format (RTF) Formato de texto enriquecido (RTF)
|
||||
Right Derecha
|
||||
Right Border Borde derecho
|
||||
Right-to-Left Text Direction Dirección de texto de derecha a izquierda
|
||||
@@ -669,6 +701,7 @@
|
||||
Studio cover Portada de Studio
|
||||
Studio handbook (sample) Manual de Studio (ejemplo)
|
||||
Style Estilo
|
||||
Style Inspector Inspector de estilos
|
||||
Style Set Conjunto de estilos
|
||||
Style based on: Estilo basado en:
|
||||
Style: APA Estilo: APA
|
||||
@@ -708,6 +741,7 @@
|
||||
Text: Texto:
|
||||
Text\nBox Cuadro de\ntexto
|
||||
Text\nDirection Dirección del\ntexto
|
||||
The clipboard is empty. El portapapeles está vacío.
|
||||
The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual.
|
||||
Theme Colors Colores del tema
|
||||
Theme Fonts Fuentes del tema
|
||||
@@ -735,6 +769,7 @@
|
||||
Underline Words Only Subrayar solo las palabras
|
||||
Undo Deshacer
|
||||
Undo {action} Deshacer {action}
|
||||
Unformatted Text Texto sin formato
|
||||
Up Arriba
|
||||
Update Field Actualizar campo
|
||||
Update Index Actualizar índice
|
||||
@@ -895,6 +930,42 @@
|
||||
Opening print dialog… Abriendo el cuadro de diálogo de impresión…
|
||||
Print failed: {error} Error al imprimir: {error}
|
||||
Couldn't open {path}: {error} No se pudo abrir {path}: {error}
|
||||
Column Selection Selección de columna
|
||||
New Table Style Nuevo estilo de tabla
|
||||
Modify Table Style Modificar estilo de tabla
|
||||
New Table Style… Nuevo estilo de tabla…
|
||||
Modify Table Style… Modificar estilo de tabla…
|
||||
Apply formatting to: Aplicar formato a:
|
||||
Whole Table Toda la tabla
|
||||
Fill color: Color de relleno:
|
||||
{count} items highlighted {count} elementos resaltados
|
||||
{n} pt {n} pt
|
||||
Not {name} Sin {name}
|
||||
Level {n} Nivel {n}
|
||||
Body Text Texto independiente
|
||||
{n} lines {n} líneas
|
||||
At least {n} Mínimo {n}
|
||||
Exactly {n} Exacto {n}
|
||||
Centered Centrado
|
||||
Character spacing Espaciado de caracteres
|
||||
Don't add space between paragraphs of the same style No agregar espacio entre párrafos del mismo estilo
|
||||
Don't hyphenate No separar con guiones
|
||||
Double strikethrough Doble tachado
|
||||
Drop cap Letra capital
|
||||
First line Primera línea
|
||||
Font color Color de fuente
|
||||
Highlight Resaltado
|
||||
Justified Justificado
|
||||
Left indent Sangría izquierda
|
||||
Line spacing Interlineado
|
||||
Outline level Nivel de esquema
|
||||
Raised/lowered Elevado/bajado
|
||||
Right indent Sangría derecha
|
||||
Scale Escala
|
||||
Space after Espacio posterior
|
||||
Space before Espacio anterior
|
||||
Suppress line numbers Suprimir números de línea
|
||||
Underline color Color de subrayado
|
||||
Letters Cartas
|
||||
E-mail Messages Mensajes de correo electrónico
|
||||
Directory Directorio
|
||||
@@ -968,3 +1039,5 @@
|
||||
Show in the Styles gallery Mostrar en la galería de estilos
|
||||
Hide from style lists Ocultar de las listas de estilos
|
||||
Built-in styles can't be deleted. Los estilos integrados no se pueden eliminar.
|
||||
Delete Table Style Eliminar estilo de tabla
|
||||
Preview Vista previa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -117,6 +117,15 @@
|
||||
Clear クリア
|
||||
Clear All Formatting すべての書式をクリア
|
||||
Clipboard クリップボード
|
||||
Clipboard Pane クリップボード ウィンドウ
|
||||
Clipboard Items クリップボードの項目
|
||||
Paste Clipboard Item クリップボードの項目を貼り付け
|
||||
Paste All すべて貼り付け
|
||||
Delete Clipboard Item クリップボードの項目を削除
|
||||
Clear All すべてクリア
|
||||
Click an item to paste it. 項目をクリックすると貼り付けます。
|
||||
Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。
|
||||
(picture or object) (画像またはオブジェクト)
|
||||
Close 閉じる
|
||||
Close Header and Footer ヘッダーとフッターを閉じる
|
||||
Collapse Selection 選択範囲を縮小
|
||||
@@ -878,6 +887,68 @@
|
||||
Opening print dialog… 印刷ダイアログを開いています…
|
||||
Print failed: {error} 印刷できませんでした: {error}
|
||||
Couldn't open {path}: {error} {path} を開けませんでした: {error}
|
||||
Column Selection 矩形選択
|
||||
Style Inspector スタイル インスペクター
|
||||
Inspect Styles at Selection 選択箇所のスタイルを調べる
|
||||
Clear Formatting Level 書式のレベルをクリア
|
||||
Characters 文字
|
||||
Direct formatting 直接の書式
|
||||
Reset to Normal 標準に戻す
|
||||
Clear Paragraph Formatting 段落の書式をクリア
|
||||
Clear Character Style 文字スタイルをクリア
|
||||
Clear Character Formatting 文字の書式をクリア
|
||||
Paste Special 形式を選択して貼り付け
|
||||
Paste Special… 形式を選択して貼り付け…
|
||||
Paste as: 貼り付ける形式:
|
||||
The clipboard is empty. クリップボードは空です。
|
||||
Formatted Text 書式付きテキスト
|
||||
Rich Text Format (RTF) リッチ テキスト形式 (RTF)
|
||||
HTML Format HTML 形式
|
||||
Unformatted Text 書式なしテキスト
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。
|
||||
Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。
|
||||
Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。
|
||||
Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。
|
||||
Advanced Find 高度な検索
|
||||
Advanced Find… 高度な検索…
|
||||
Go To… ジャンプ…
|
||||
Reading Highlight 検索結果を強調表示
|
||||
New Table Style 新しい表スタイル
|
||||
Modify Table Style 表スタイルの変更
|
||||
New Table Style… 新しい表スタイル…
|
||||
Modify Table Style… 表スタイルの変更…
|
||||
Apply formatting to: 書式の適用先:
|
||||
Whole Table 表全体
|
||||
Fill color: 塗りつぶしの色:
|
||||
{count} items highlighted {count} 件を強調表示しました
|
||||
{n} pt {n} pt
|
||||
Not {name} {name} なし
|
||||
Level {n} レベル {n}
|
||||
Body Text 本文
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小値 {n}
|
||||
Exactly {n} 固定値 {n}
|
||||
Centered 中央揃え
|
||||
Character spacing 文字間隔
|
||||
Don't add space between paragraphs of the same style 同じスタイルの段落間に間隔を追加しない
|
||||
Don't hyphenate ハイフネーションなし
|
||||
Double strikethrough 二重取り消し線
|
||||
Drop cap ドロップ キャップ
|
||||
First line 最初の行
|
||||
Font color フォントの色
|
||||
Highlight 蛍光ペン
|
||||
Justified 両端揃え
|
||||
Left indent 左インデント
|
||||
Line spacing 行間
|
||||
Outline level アウトライン レベル
|
||||
Raised/lowered 上げ/下げ
|
||||
Right indent 右インデント
|
||||
Scale 倍率
|
||||
Space after 段落後の間隔
|
||||
Space before 段落前の間隔
|
||||
Suppress line numbers 行番号を表示しない
|
||||
Underline color 下線の色
|
||||
Letters レター
|
||||
E-mail Messages 電子メール
|
||||
Directory ディレクトリ
|
||||
@@ -951,3 +1022,5 @@
|
||||
Show in the Styles gallery スタイル ギャラリーに表示
|
||||
Hide from style lists スタイルの一覧に表示しない
|
||||
Built-in styles can't be deleted. 組み込みスタイルは削除できません。
|
||||
Delete Table Style 表スタイルの削除
|
||||
Preview プレビュー
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 420 and column 13.
|
@@ -47,7 +47,7 @@ pub struct LangInfo {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 7] = [
|
||||
pub static LANGUAGES: [LangInfo; 9] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
|
||||
@@ -60,6 +60,12 @@ pub static LANGUAGES: [LangInfo; 7] = [
|
||||
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
|
||||
// catalog yet (the European vocabulary differs), so they stay in English.
|
||||
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
|
||||
// `sr-Cyrl-*` resolve here.
|
||||
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
|
||||
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
|
||||
LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
||||
@@ -119,6 +119,15 @@
|
||||
Clear Limpar
|
||||
Clear All Formatting Limpar toda a formatação
|
||||
Clipboard Área de transferência
|
||||
Clipboard Pane Painel da área de transferência
|
||||
Clipboard Items Itens da área de transferência
|
||||
Paste Clipboard Item Colar item da área de transferência
|
||||
Paste All Colar tudo
|
||||
Delete Clipboard Item Excluir item da área de transferência
|
||||
Clear All Limpar tudo
|
||||
Click an item to paste it. Clique em um item para colá-lo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui.
|
||||
(picture or object) (imagem ou objeto)
|
||||
Close Fechar
|
||||
Close Header and Footer Fechar cabeçalho e rodapé
|
||||
Collapse Selection Recolher seleção
|
||||
@@ -890,6 +899,68 @@
|
||||
Opening print dialog… Abrindo a caixa de diálogo de impressão…
|
||||
Print failed: {error} Falha na impressão: {error}
|
||||
Couldn't open {path}: {error} Não foi possível abrir {path}: {error}
|
||||
Column Selection Seleção de coluna
|
||||
Style Inspector Inspetor de estilos
|
||||
Inspect Styles at Selection Inspecionar estilos na seleção
|
||||
Clear Formatting Level Limpar nível de formatação
|
||||
Characters Caracteres
|
||||
Direct formatting Formatação direta
|
||||
Reset to Normal Redefinir para Normal
|
||||
Clear Paragraph Formatting Limpar formatação do parágrafo
|
||||
Clear Character Style Limpar estilo de caractere
|
||||
Clear Character Formatting Limpar formatação de caractere
|
||||
Paste Special Colar especial
|
||||
Paste Special… Colar especial…
|
||||
Paste as: Colar como:
|
||||
The clipboard is empty. A área de transferência está vazia.
|
||||
Formatted Text Texto formatado
|
||||
Rich Text Format (RTF) Rich Text Format (RTF)
|
||||
HTML Format Formato HTML
|
||||
Unformatted Text Texto sem formatação
|
||||
Result Resultado
|
||||
Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado.
|
||||
Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação.
|
||||
Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação.
|
||||
Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples.
|
||||
Advanced Find Localização avançada
|
||||
Advanced Find… Localização avançada…
|
||||
Go To… Ir para…
|
||||
Reading Highlight Realçar resultados
|
||||
New Table Style Novo estilo de tabela
|
||||
Modify Table Style Modificar estilo de tabela
|
||||
New Table Style… Novo estilo de tabela…
|
||||
Modify Table Style… Modificar estilo de tabela…
|
||||
Apply formatting to: Aplicar formatação a:
|
||||
Whole Table Tabela inteira
|
||||
Fill color: Cor de preenchimento:
|
||||
{count} items highlighted {count} itens realçados
|
||||
{n} pt {n} pt
|
||||
Not {name} Sem {name}
|
||||
Level {n} Nível {n}
|
||||
Body Text Corpo do texto
|
||||
{n} lines {n} linhas
|
||||
At least {n} Pelo menos {n}
|
||||
Exactly {n} Exatamente {n}
|
||||
Centered Centralizado
|
||||
Character spacing Espaçamento entre caracteres
|
||||
Don't add space between paragraphs of the same style Não adicionar espaço entre parágrafos do mesmo estilo
|
||||
Don't hyphenate Não hifenizar
|
||||
Double strikethrough Tachado duplo
|
||||
Drop cap Capitular
|
||||
First line Primeira linha
|
||||
Font color Cor da fonte
|
||||
Highlight Realce
|
||||
Justified Justificado
|
||||
Left indent Recuo à esquerda
|
||||
Line spacing Espaçamento entre linhas
|
||||
Outline level Nível de tópicos
|
||||
Raised/lowered Elevado/rebaixado
|
||||
Right indent Recuo à direita
|
||||
Scale Escala
|
||||
Space after Espaço depois
|
||||
Space before Espaço antes
|
||||
Suppress line numbers Suprimir números de linha
|
||||
Underline color Cor do sublinhado
|
||||
Letters Cartas
|
||||
E-mail Messages Mensagens de email
|
||||
Directory Diretório
|
||||
@@ -963,3 +1034,5 @@
|
||||
Show in the Styles gallery Mostrar na galeria de estilos
|
||||
Hide from style lists Ocultar das listas de estilos
|
||||
Built-in styles can't be deleted. Estilos integrados não podem ser excluídos.
|
||||
Delete Table Style Excluir estilo de tabela
|
||||
Preview Visualização
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,11 @@ fn system_locales_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("fr-FR"), None);
|
||||
assert_eq!(lang_from_tag(""), None);
|
||||
assert_eq!(lang_from_tag("_"), None);
|
||||
assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script");
|
||||
assert_eq!(lang_from_tag("sr-RS"), Some(lang("sr")));
|
||||
assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr")));
|
||||
assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn")));
|
||||
assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -209,6 +214,69 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serbian_covers_the_entire_existing_interface_catalog() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let reference = keys(lang("zh-hans").0.source);
|
||||
assert_eq!(keys(lang("sr").0.source), reference);
|
||||
assert_eq!(keys(lang("sr-latn").0.source), reference);
|
||||
let (cyr, lat) = (lang("sr"), lang("sr-latn"));
|
||||
assert_eq!(tr(cyr, "Home"), "Почетак");
|
||||
assert_eq!(tr(lat, "Home"), "Početak");
|
||||
assert_eq!(tr(cyr, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(lat, "Exported {path}"), &[("path", "draft-1.docx")]), "Izvezeno: draft-1.docx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("sr").0.source);
|
||||
assert!(errors.is_empty());
|
||||
let chars: std::collections::HashSet<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("sr-latn").0.source);
|
||||
assert!(errors.is_empty());
|
||||
// Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals).
|
||||
let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ'];
|
||||
let chars: std::collections::HashSet<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serbian_locales_resolve_by_script() {
|
||||
let cyr = lang("sr");
|
||||
let lat = lang("sr-latn");
|
||||
for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(cyr), "{tag}");
|
||||
}
|
||||
for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(lat), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat));
|
||||
assert_eq!(tr(cyr, "File"), "Фајл");
|
||||
assert_eq!(tr(lat, "File"), "Fajl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
let pt = lang("pt-br");
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
Address\nBlock Блок\nадреси
|
||||
Adjust Налаштувати
|
||||
Advanced Додатково
|
||||
Advanced Find Розширений пошук
|
||||
Advanced Find… Розширений пошук…
|
||||
After: Після:
|
||||
Agents Агенти
|
||||
Align Вирівняти
|
||||
@@ -106,6 +108,7 @@
|
||||
Character Scale Масштаб символів
|
||||
Character Shading Заливка символів
|
||||
Character Spacing Інтервал між символами
|
||||
Characters Символи
|
||||
Characters (no spaces) Символи (без пробілів)
|
||||
Characters (with spaces) Символи (з пробілами)
|
||||
Chart Діаграма
|
||||
@@ -117,8 +120,21 @@
|
||||
Citation Style Стиль цитування
|
||||
Citations & Bibliography Цитати й бібліографія
|
||||
Clear Очистити
|
||||
Clear All Очистити все
|
||||
Clear All Formatting Очистити все форматування
|
||||
Clear Character Formatting Очистити форматування символів
|
||||
Clear Character Style Очистити стиль символів
|
||||
Clear Formatting Level Очистити рівень форматування
|
||||
Clear Paragraph Formatting Очистити форматування абзацу
|
||||
Clipboard Буфер обміну
|
||||
Clipboard Pane Панель буфера обміну
|
||||
Clipboard Items Елементи буфера обміну
|
||||
Paste Clipboard Item Вставити елемент буфера обміну
|
||||
Paste All Вставити все
|
||||
Delete Clipboard Item Видалити елемент буфера обміну
|
||||
Click an item to paste it. Клацніть елемент, щоб вставити його.
|
||||
Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете.
|
||||
(picture or object) (зображення або об'єкт)
|
||||
Close Закрити
|
||||
Close Header and Footer Закрити колонтитули
|
||||
Collapse Selection Зняти виділення
|
||||
@@ -188,6 +204,7 @@
|
||||
Dictate Диктувати
|
||||
Different First Page Інша перша сторінка
|
||||
Different Odd & Even Pages Різні парні й непарні сторінки
|
||||
Direct formatting Пряме форматування
|
||||
Discord Discord
|
||||
Discord community Спільнота Discord
|
||||
Display Відображення
|
||||
@@ -215,6 +232,7 @@
|
||||
Drawing Tools Знаряддя малювання
|
||||
Drawing\nCanvas Полотно\nмалювання
|
||||
Drop Cap Буквиця
|
||||
Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст.
|
||||
Edit Footer Редагувати нижній колонтитул
|
||||
Edit Header Редагувати верхній колонтитул
|
||||
Edit Recipient List Редагувати список одержувачів
|
||||
@@ -271,12 +289,14 @@
|
||||
Format Page Numbers Формат номерів сторінок
|
||||
Format Page Numbers… Формат номерів сторінок…
|
||||
Format Painter Пензель форматування
|
||||
Formatted Text Форматований текст
|
||||
Formatting at Selection Форматування виділення
|
||||
Formula Формула
|
||||
General Загальні
|
||||
Go To Перейти
|
||||
Go to Перейти
|
||||
Go To (⌘⌥G) Перейти (⌘⌥G)
|
||||
Go To… Перейти…
|
||||
Grab bag Різне
|
||||
Greeting Line Рядок привітання
|
||||
Greeting\nLine Рядок\nпривітання
|
||||
@@ -301,6 +321,7 @@
|
||||
Home Основне
|
||||
Horizontal Горизонтальна
|
||||
Horizontal Line Горизонтальна лінія
|
||||
HTML Format Формат HTML
|
||||
Hyphenation Перенесення слів
|
||||
Icons Піктограми
|
||||
Ignore All Ігнорувати всі
|
||||
@@ -342,6 +363,7 @@
|
||||
Insert\nFootnote Вставити\nвиноску
|
||||
Inside Borders Внутрішні межі
|
||||
Inspect Document Перевірити документ
|
||||
Inspect Styles at Selection Переглянути стилі у виділенні
|
||||
Interface language: Мова інтерфейсу:
|
||||
Italic Курсив
|
||||
Join the ArtCraft community on Discord Приєднатися до спільноти ArtCraft у Discord
|
||||
@@ -352,6 +374,7 @@
|
||||
Keep Text Only Зберегти лише текст
|
||||
Keep with Next Не відривати від наступного
|
||||
Keep with next Не відривати від наступного
|
||||
Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту.
|
||||
Labels Етикетки
|
||||
Landscape Альбомна
|
||||
Language Мова
|
||||
@@ -493,7 +516,10 @@
|
||||
Paragraphs Абзаци
|
||||
Paste Вставити
|
||||
Paste (⌘V) Вставити (⌘V)
|
||||
Paste as: Вставити як:
|
||||
Paste Formatting Вставити форматування
|
||||
Paste Special Спеціальна вставка
|
||||
Paste Special… Спеціальна вставка…
|
||||
Paste: Keep Text Only Вставлення: зберегти лише текст
|
||||
Paste: Merge Formatting Вставлення: об'єднати форматування
|
||||
PDF document (*.pdf) Документ PDF (*.pdf)
|
||||
@@ -528,6 +554,9 @@
|
||||
Read Mode Режим читання
|
||||
Read\nAloud Читати\nвголос
|
||||
Read\nMode Режим\nчитання
|
||||
Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування.
|
||||
Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування.
|
||||
Reading Highlight Виділення під час читання
|
||||
Recent Останні
|
||||
Record Macro Записати макрос
|
||||
Recover Unsaved Documents Відновити незбережені документи
|
||||
@@ -558,6 +587,7 @@
|
||||
Research Дослідження
|
||||
Researcher Дослідник
|
||||
Reset Picture Скинути зображення
|
||||
Reset to Normal Скинути до звичайного
|
||||
Resolve Вирішити
|
||||
Resolved Вирішено
|
||||
Restart at 1 Почати з 1
|
||||
@@ -565,6 +595,7 @@
|
||||
Restart Each Section Починати в кожному розділі
|
||||
Restrict Editing Обмежити редагування
|
||||
Restrict\nEditing Обмежити\nредагування
|
||||
Result Результат
|
||||
Results Результати
|
||||
Reverse the order Зворотний порядок
|
||||
Review Рецензування
|
||||
@@ -572,6 +603,7 @@
|
||||
Reviewing Pane Панель рецензування
|
||||
Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями
|
||||
Rich Text Format (*.rtf) Форматований текст (*.rtf)
|
||||
Rich Text Format (RTF) Формат RTF (Rich Text Format)
|
||||
Right Праворуч
|
||||
Right Border Права межа
|
||||
Right-to-Left Text Direction Напрямок тексту справа наліво
|
||||
@@ -671,6 +703,7 @@
|
||||
Studio handbook (sample) Посібник студії (приклад)
|
||||
Style Стиль
|
||||
Style based on: Стиль на основі:
|
||||
Style Inspector Інспектор стилів
|
||||
Style Set Набір стилів
|
||||
Style: APA Стиль: APA
|
||||
Styles Стилі
|
||||
@@ -709,6 +742,7 @@
|
||||
Text: Текст:
|
||||
Text\nBox Текстове\nполе
|
||||
Text\nDirection Напрямок\nтексту
|
||||
The clipboard is empty. Буфер обміну порожній.
|
||||
The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу.
|
||||
Theme Colors Кольори теми
|
||||
Theme Fonts Шрифти теми
|
||||
@@ -735,6 +769,7 @@
|
||||
Underline Words Only Підкреслити лише слова
|
||||
Undo Скасувати
|
||||
Undo {action} Скасувати {action}
|
||||
Unformatted Text Неформатований текст
|
||||
Up Угору
|
||||
Update Field Оновити поле
|
||||
Update Index Оновити покажчик
|
||||
@@ -890,6 +925,42 @@
|
||||
Opening print dialog… Відкриття діалогу друку…
|
||||
Print failed: {error} Не вдалося надрукувати: {error}
|
||||
Couldn't open {path}: {error} Не вдалося відкрити {path}: {error}
|
||||
Column Selection Виділення стовпця
|
||||
New Table Style Новий стиль таблиці
|
||||
Modify Table Style Змінити стиль таблиці
|
||||
New Table Style… Новий стиль таблиці…
|
||||
Modify Table Style… Змінити стиль таблиці…
|
||||
Apply formatting to: Застосувати форматування до:
|
||||
Whole Table Уся таблиця
|
||||
Fill color: Колір заливки:
|
||||
{count} items highlighted Виділено елементів: {count}
|
||||
{n} pt {n} пт
|
||||
Not {name} Без: {name}
|
||||
Level {n} Рівень {n}
|
||||
Body Text Основний текст
|
||||
{n} lines Рядків: {n}
|
||||
At least {n} Щонайменше {n}
|
||||
Exactly {n} Точно {n}
|
||||
Centered По центру
|
||||
Character spacing Інтервал між символами
|
||||
Don't add space between paragraphs of the same style Не додавати інтервал між абзацами одного стилю
|
||||
Don't hyphenate Без перенесення слів
|
||||
Double strikethrough Подвійне закреслення
|
||||
Drop cap Буквиця
|
||||
First line Перший рядок
|
||||
Font color Колір шрифту
|
||||
Highlight Виділення кольором
|
||||
Justified За шириною
|
||||
Left indent Відступ ліворуч
|
||||
Line spacing Міжрядковий інтервал
|
||||
Outline level Рівень плану
|
||||
Raised/lowered Піднято/опущено
|
||||
Right indent Відступ праворуч
|
||||
Scale Масштаб
|
||||
Space after Інтервал після
|
||||
Space before Інтервал перед
|
||||
Suppress line numbers Без номерів рядків
|
||||
Underline color Колір підкреслення
|
||||
Letters Листи
|
||||
E-mail Messages Повідомлення електронної пошти
|
||||
Directory Каталог
|
||||
@@ -963,3 +1034,5 @@
|
||||
Show in the Styles gallery Показувати в колекції стилів
|
||||
Hide from style lists Сховати зі списків стилів
|
||||
Built-in styles can't be deleted. Вбудовані стилі не можна видалити.
|
||||
Delete Table Style Видалити стиль таблиці
|
||||
Preview Попередній перегляд
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -128,6 +128,15 @@
|
||||
Clear 清除
|
||||
Clear All Formatting 清除所有格式
|
||||
Clipboard 剪贴板
|
||||
Clipboard Pane 剪贴板窗格
|
||||
Clipboard Items 剪贴板项目
|
||||
Paste Clipboard Item 粘贴剪贴板项目
|
||||
Paste All 全部粘贴
|
||||
Delete Clipboard Item 删除剪贴板项目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 单击项目即可粘贴。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。
|
||||
(picture or object) (图片或对象)
|
||||
Close 关闭
|
||||
Close Header and Footer 关闭页眉和页脚
|
||||
Collapse Selection 折叠选定内容
|
||||
@@ -889,6 +898,68 @@
|
||||
Opening print dialog… 正在打开打印对话框…
|
||||
Print failed: {error} 打印失败:{error}
|
||||
Couldn't open {path}: {error} 无法打开 {path}:{error}
|
||||
Column Selection 矩形选择
|
||||
Style Inspector 样式检查器
|
||||
Inspect Styles at Selection 检查所选内容的样式
|
||||
Clear Formatting Level 清除格式级别
|
||||
Characters 字符
|
||||
Direct formatting 直接格式
|
||||
Reset to Normal 重置为正文
|
||||
Clear Paragraph Formatting 清除段落格式
|
||||
Clear Character Style 清除字符样式
|
||||
Clear Character Formatting 清除字符格式
|
||||
Paste Special 选择性粘贴
|
||||
Paste Special… 选择性粘贴…
|
||||
Paste as: 粘贴为:
|
||||
The clipboard is empty. 剪贴板为空。
|
||||
Formatted Text 带格式文本
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 无格式文本
|
||||
Result 结果
|
||||
Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。
|
||||
Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。
|
||||
Advanced Find 高级查找
|
||||
Advanced Find… 高级查找…
|
||||
Go To… 定位…
|
||||
Reading Highlight 突出显示查找结果
|
||||
New Table Style 新建表格样式
|
||||
Modify Table Style 修改表格样式
|
||||
New Table Style… 新建表格样式…
|
||||
Modify Table Style… 修改表格样式…
|
||||
Apply formatting to: 将格式应用于:
|
||||
Whole Table 整个表格
|
||||
Fill color: 填充颜色:
|
||||
{count} items highlighted 已突出显示 {count} 项
|
||||
{n} pt {n} 磅
|
||||
Not {name} 非{name}
|
||||
Level {n} {n} 级
|
||||
Body Text 正文文本
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小值 {n}
|
||||
Exactly {n} 固定值 {n}
|
||||
Centered 居中
|
||||
Character spacing 字符间距
|
||||
Don't add space between paragraphs of the same style 相同样式的段落间不添加间距
|
||||
Don't hyphenate 不断字
|
||||
Double strikethrough 双删除线
|
||||
Drop cap 首字下沉
|
||||
First line 首行
|
||||
Font color 字体颜色
|
||||
Highlight 突出显示
|
||||
Justified 两端对齐
|
||||
Left indent 左缩进
|
||||
Line spacing 行距
|
||||
Outline level 大纲级别
|
||||
Raised/lowered 提升/降低
|
||||
Right indent 右缩进
|
||||
Scale 缩放
|
||||
Space after 段后间距
|
||||
Space before 段前间距
|
||||
Suppress line numbers 取消行号
|
||||
Underline color 下划线颜色
|
||||
Letters 信函
|
||||
E-mail Messages 电子邮件
|
||||
Directory 目录
|
||||
@@ -962,3 +1033,5 @@
|
||||
Show in the Styles gallery 在样式库中显示
|
||||
Hide from style lists 在样式列表中隐藏
|
||||
Built-in styles can't be deleted. 内置样式不能删除。
|
||||
Delete Table Style 删除表格样式
|
||||
Preview 预览
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -118,6 +118,15 @@
|
||||
Clear 清除
|
||||
Clear All Formatting 清除所有格式設定
|
||||
Clipboard 剪貼簿
|
||||
Clipboard Pane 剪貼簿窗格
|
||||
Clipboard Items 剪貼簿項目
|
||||
Paste Clipboard Item 貼上剪貼簿項目
|
||||
Paste All 全部貼上
|
||||
Delete Clipboard Item 刪除剪貼簿項目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 按一下項目即可貼上。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。
|
||||
(picture or object) (圖片或物件)
|
||||
Close 關閉
|
||||
Close Header and Footer 關閉頁首及頁尾
|
||||
Collapse Selection 摺疊選取範圍
|
||||
@@ -879,6 +888,68 @@
|
||||
Opening print dialog… 正在開啟列印對話方塊…
|
||||
Print failed: {error} 列印失敗:{error}
|
||||
Couldn't open {path}: {error} 無法開啟 {path}:{error}
|
||||
Column Selection 矩形選取
|
||||
Style Inspector 樣式檢查器
|
||||
Inspect Styles at Selection 檢查選取範圍的樣式
|
||||
Clear Formatting Level 清除格式層級
|
||||
Characters 字元
|
||||
Direct formatting 直接格式設定
|
||||
Reset to Normal 重設為內文
|
||||
Clear Paragraph Formatting 清除段落格式設定
|
||||
Clear Character Style 清除字元樣式
|
||||
Clear Character Formatting 清除字元格式設定
|
||||
Paste Special 選擇性貼上
|
||||
Paste Special… 選擇性貼上…
|
||||
Paste as: 貼上為:
|
||||
The clipboard is empty. 剪貼簿是空的。
|
||||
Formatted Text 格式化文字
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 純文字
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。
|
||||
Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。
|
||||
Advanced Find 進階尋找
|
||||
Advanced Find… 進階尋找…
|
||||
Go To… 移至…
|
||||
Reading Highlight 醒目提示尋找結果
|
||||
New Table Style 新增表格樣式
|
||||
Modify Table Style 修改表格樣式
|
||||
New Table Style… 新增表格樣式…
|
||||
Modify Table Style… 修改表格樣式…
|
||||
Apply formatting to: 將格式套用至:
|
||||
Whole Table 整個表格
|
||||
Fill color: 填滿色彩:
|
||||
{count} items highlighted 已醒目提示 {count} 個項目
|
||||
{n} pt {n} 點
|
||||
Not {name} 非{name}
|
||||
Level {n} 第 {n} 階
|
||||
Body Text 本文
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小行高 {n}
|
||||
Exactly {n} 固定行高 {n}
|
||||
Centered 置中
|
||||
Character spacing 字元間距
|
||||
Don't add space between paragraphs of the same style 相同樣式的段落之間不新增間距
|
||||
Don't hyphenate 不斷字
|
||||
Double strikethrough 雙刪除線
|
||||
Drop cap 首字放大
|
||||
First line 第一行
|
||||
Font color 字型色彩
|
||||
Highlight 醒目提示
|
||||
Justified 左右對齊
|
||||
Left indent 左縮排
|
||||
Line spacing 行距
|
||||
Outline level 大綱階層
|
||||
Raised/lowered 上移/下移
|
||||
Right indent 右縮排
|
||||
Scale 縮放
|
||||
Space after 段落後間距
|
||||
Space before 段落前間距
|
||||
Suppress line numbers 隱藏行號
|
||||
Underline color 底線色彩
|
||||
Letters 信函
|
||||
E-mail Messages 電子郵件
|
||||
Directory 目錄
|
||||
@@ -952,3 +1023,5 @@
|
||||
Show in the Styles gallery 在樣式庫中顯示
|
||||
Hide from style lists 從樣式清單中隱藏
|
||||
Built-in styles can't be deleted. 無法刪除內建樣式。
|
||||
Delete Table Style 刪除表格樣式
|
||||
Preview 預覽
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 421 and column 13.
|
@@ -860,6 +860,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
|
||||
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
|
||||
}
|
||||
"zoomIn" | "zoomOut" => {
|
||||
pen.circle(8.5, 8.5, 5.5, c);
|
||||
pen.line_c(&[(12.5, 12.5), (17.5, 17.5)], a);
|
||||
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
|
||||
if name == "zoomIn" {
|
||||
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
|
||||
}
|
||||
}
|
||||
"zoom100" => pen.text(10.0, 10.0, 7.5, "100", a, true),
|
||||
"onePage" => pen.page(5.0, 2.0, 15.0, 18.0),
|
||||
"multiplePages" => {
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn key_name(k: Key) -> Option<&'static str> {
|
||||
Key::F3 => "F3",
|
||||
Key::F5 => "F5",
|
||||
Key::F7 => "F7",
|
||||
Key::F8 => "F8",
|
||||
Key::F9 => "F9",
|
||||
Key::F12 => "F12",
|
||||
Key::A => "A",
|
||||
@@ -185,7 +186,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" };
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
}
|
||||
egui::Event::Copy | egui::Event::Cut => {
|
||||
|
||||
@@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
}
|
||||
"Table Layout" => vec![
|
||||
m("Select", "table.selectCell"),
|
||||
c("View Gridlines", "view.gridlines"),
|
||||
c("View Gridlines", "table.viewGridlines"),
|
||||
c("Properties", "table.properties"),
|
||||
m("Delete", "table.deleteCells"),
|
||||
c("Insert Above", "table.insertRowAbove"),
|
||||
@@ -590,20 +590,36 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let mut alt_released = false;
|
||||
let mut f10 = false;
|
||||
for e in &events {
|
||||
if let egui::Event::Key { key, pressed, modifiers, .. } = e {
|
||||
if is_alt(*key) {
|
||||
if *pressed && modifiers.any() {
|
||||
// Alt combined with another modifier is a shortcut, not a keytip chord.
|
||||
match e {
|
||||
egui::Event::Key { key, pressed, repeat, modifiers, .. } => {
|
||||
if is_alt(*key) {
|
||||
if *pressed {
|
||||
// A new Alt press starts a fresh chord (auto-repeat keeps the current one).
|
||||
if !*repeat {
|
||||
app.ui.alt_chord_used = false;
|
||||
}
|
||||
if modifiers.any() {
|
||||
// Alt combined with another modifier is a shortcut, not a keytip chord.
|
||||
other_key = true;
|
||||
}
|
||||
} else if !modifiers.any() {
|
||||
// Alt+click and Alt+drag (column selection), or Alt+key held across frames,
|
||||
// end with a bare Alt release too; only an untouched Alt tap counts.
|
||||
alt_released = !std::mem::take(&mut app.ui.alt_chord_used);
|
||||
}
|
||||
} else if *pressed {
|
||||
other_key = true;
|
||||
} else if !*pressed && !modifiers.any() {
|
||||
alt_released = true;
|
||||
}
|
||||
} else if *pressed {
|
||||
other_key = true;
|
||||
if *key == egui::Key::F10 && !modifiers.any() {
|
||||
f10 = true;
|
||||
app.ui.alt_chord_used = true;
|
||||
if *key == egui::Key::F10 && !modifiers.any() {
|
||||
f10 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => {
|
||||
other_key = true;
|
||||
app.ui.alt_chord_used = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Route letter/navigation keys to the keytips while they are showing.
|
||||
@@ -739,6 +755,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+drag selects a column (#237); releasing Alt afterwards must not open keytips. A bare
|
||||
/// Alt tap still does (off macOS, where Option types accents).
|
||||
#[test]
|
||||
fn alt_click_or_drag_does_not_arm_keytips_on_release() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.ui.backstage = false;
|
||||
a.dialog = None;
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
let key = |pressed, modifiers| egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let pos = egui::pos2(400.0, 300.0);
|
||||
let frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| logic(a, ui.ctx())).drop_without_applying_deltas();
|
||||
};
|
||||
// Alt down, drag with the mouse over several frames, Alt up.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }]);
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(pos + egui::vec2(60.0, 40.0))]);
|
||||
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
assert_eq!(a.ui.keytips, Phase::Off, "Alt+drag then Alt up leaves keytips off");
|
||||
// Alt held while a key is pressed in a later frame: also a chord.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: alt }]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
assert_eq!(a.ui.keytips, Phase::Off, "Alt+key then Alt up leaves keytips off");
|
||||
// A bare tap (press and release in separate frames) arms them.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
let tap = if cfg!(target_os = "macos") { Phase::Off } else { Phase::Tabs };
|
||||
assert_eq!(a.ui.keytips, tap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_state_machine() {
|
||||
let mut a = app();
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod panes;
|
||||
pub mod previews;
|
||||
pub mod read_aloud;
|
||||
pub mod ribbon;
|
||||
pub mod scroll;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod window_geometry;
|
||||
@@ -104,6 +105,10 @@ pub struct UiState {
|
||||
/// Keytips (Alt) state; not persisted, it resets each session.
|
||||
#[serde(skip)]
|
||||
pub keytips: crate::keytips::Phase,
|
||||
/// Since the last Alt press, another key or a mouse button was pressed: the Alt release
|
||||
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
|
||||
#[serde(skip)]
|
||||
pub alt_chord_used: bool,
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
|
||||
pub dark_page: bool,
|
||||
}
|
||||
@@ -127,6 +132,7 @@ impl Default for UiState {
|
||||
read_aloud_rate: 1.0,
|
||||
read_aloud_skip_citations: true,
|
||||
keytips: crate::keytips::Phase::Off,
|
||||
alt_chord_used: false,
|
||||
dark_page: false,
|
||||
}
|
||||
}
|
||||
@@ -187,6 +193,10 @@ pub struct WordApp {
|
||||
autosave_path: Option<std::path::PathBuf>,
|
||||
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
|
||||
file_dialog: Option<file_dialogs::PendingDialog>,
|
||||
/// File › Info: the property field being typed in and the document revision after its last
|
||||
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
|
||||
/// between, such as an Undo, starts a new one).
|
||||
pub(crate) info_editing: Option<(&'static str, u64)>,
|
||||
}
|
||||
|
||||
/// The answer to "Do you want to save changes?" (`ui.saveChanges`).
|
||||
@@ -246,6 +256,7 @@ impl WordApp {
|
||||
change_picture_target: None,
|
||||
recipient_list_pending: false,
|
||||
file_dialog: None,
|
||||
info_editing: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +347,10 @@ impl WordApp {
|
||||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
// A save is a save, download or not: advance the revision and modified stamp (#262).
|
||||
if matches!(id, "file.save" | "file.saveAs") {
|
||||
self.session.stamp_save();
|
||||
}
|
||||
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
@@ -475,6 +490,7 @@ impl WordApp {
|
||||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "options".into();
|
||||
}
|
||||
"pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)),
|
||||
other => self.dialog = dialogs::Dialog::open(other, self),
|
||||
}
|
||||
}
|
||||
@@ -1076,6 +1092,91 @@ mod tests {
|
||||
assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out");
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom In from a fit mode zoomed *out*: Page Width showed 163% but Zoom In
|
||||
/// stepped from the stale manual 100% to 110%.
|
||||
#[test]
|
||||
fn view_tab_zoom_steps_from_the_shown_zoom_in_fit_modes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("ui.tab", json!({"tab": "View"})).unwrap();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
|
||||
let frame = |a: &mut WordApp| {
|
||||
for _ in 0..4 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
a.canvas.scale / canvas::PX_PER_PT
|
||||
};
|
||||
let base = frame(&mut a);
|
||||
assert!((base - 1.0).abs() < 1e-3);
|
||||
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
|
||||
a.run(fit, json!({})).unwrap();
|
||||
let fitted = frame(&mut a);
|
||||
assert!((fitted - base).abs() > 0.05, "{fit} changes the zoom");
|
||||
assert!((a.session.view.zoom - fitted).abs() < 1e-3, "{fit}: session zoom follows the shown zoom");
|
||||
a.run("view.zoomIn", json!({})).unwrap();
|
||||
let zin = frame(&mut a);
|
||||
assert!(zin > fitted, "{fit}: Zoom In zooms in: {fitted} -> {zin}");
|
||||
a.run(fit, json!({})).unwrap();
|
||||
frame(&mut a);
|
||||
a.run("view.zoomOut", json!({})).unwrap();
|
||||
let zout = frame(&mut a);
|
||||
assert!(zout < fitted, "{fit}: Zoom Out zooms out: {fitted} -> {zout}");
|
||||
// The Zoom dialog opens at the shown zoom.
|
||||
a.run(fit, json!({})).unwrap();
|
||||
frame(&mut a);
|
||||
a.run("ui.dialog", json!({"name": "zoom"})).unwrap();
|
||||
assert!(matches!(a.dialog, Some(dialogs::Dialog::Zoom { percent }) if (percent - (fitted * 100.0).round()).abs() < 1.0));
|
||||
a.dialog = None;
|
||||
}
|
||||
a.run("view.zoom100", json!({})).unwrap();
|
||||
assert!((frame(&mut a) - 1.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about
|
||||
/// three lines.
|
||||
#[test]
|
||||
fn touchpad_scrolls_one_to_one_and_wheel_notches_ease_in() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE };
|
||||
let start = a.canvas.scroll_offset.y;
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]);
|
||||
assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame");
|
||||
// Fingers lift; the coast after it is left to the unit tests (crate::scroll).
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, 0.0, egui::TouchPhase::End)]);
|
||||
a.canvas.wheel = Default::default();
|
||||
let at = a.canvas.scroll_offset.y;
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Line, -1.0, egui::TouchPhase::Move)]);
|
||||
let first = a.canvas.scroll_offset.y - at;
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT);
|
||||
assert!(first > 0.0 && first < notch, "a notch eases in: {first}");
|
||||
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
|
||||
}
|
||||
|
||||
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
|
||||
#[test]
|
||||
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {
|
||||
|
||||
+282
-1
@@ -1,4 +1,4 @@
|
||||
//! Side panes: Navigation (headings, pages, search results), Styles, Comments.
|
||||
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
|
||||
|
||||
use egui::{Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -16,6 +16,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| nav(app, ui));
|
||||
}
|
||||
if app.session.view.clipboard_pane {
|
||||
egui::Panel::left("clipboard_pane")
|
||||
.default_size(250.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| clipboard(app, ui));
|
||||
}
|
||||
if app.session.view.styles_pane {
|
||||
egui::Panel::right("styles_pane")
|
||||
.default_size(250.0)
|
||||
@@ -23,6 +30,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| styles(app, ui));
|
||||
}
|
||||
if app.session.view.style_inspector {
|
||||
egui::Panel::right("style_inspector")
|
||||
.default_size(240.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| inspector(app, ui));
|
||||
}
|
||||
if app.session.view.comments_pane {
|
||||
egui::Panel::right("comments_pane")
|
||||
.default_size(290.0)
|
||||
@@ -138,6 +152,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
|
||||
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Clipboard") {
|
||||
let _ = app.run("edit.clipboardPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
|
||||
let _ = app.run("edit.pasteAllClipboard", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
|
||||
let _ = app.run("edit.clearClipboard", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." };
|
||||
ui.label(egui::RichText::new(tl!(hint)).small().weak());
|
||||
ui.separator();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (i, preview) in items.iter().enumerate() {
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() };
|
||||
let mut job =
|
||||
egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() });
|
||||
job.wrap.max_width = ui.available_width();
|
||||
job.wrap.max_rows = 3;
|
||||
if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() {
|
||||
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if ui.small_button(tl!("Delete")).clicked() {
|
||||
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Styles") {
|
||||
let _ = app.run("view.stylesPane", json!({"value": false}));
|
||||
@@ -163,6 +222,9 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
if ui.button(tl!("Manage Styles…")).clicked() {
|
||||
let _ = app.run("styles.manage", json!({}));
|
||||
}
|
||||
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
|
||||
let _ = app.run("styles.inspector", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
let mut list: Vec<(String, String, bool)> = app
|
||||
.session
|
||||
@@ -267,3 +329,222 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
let _ = vec2(0.0, 0.0);
|
||||
}
|
||||
|
||||
/// Style Inspector: the paragraph and character levels at the caret, each with its style and the
|
||||
/// direct formatting on top, and a button to clear each level.
|
||||
fn inspector(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Style Inspector") {
|
||||
let _ = app.run("styles.inspector", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
// Read-only, so call it directly: running the command every frame would pay for the command
|
||||
// machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane.
|
||||
let r = wordcraft_engine::cmd::inspector::inspect(&app.session);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let none = tl!("None").to_string();
|
||||
let para = &r["paragraph"];
|
||||
let chr = &r["character"];
|
||||
let pstyle = para["styleName"].as_str().unwrap_or("Normal").to_string();
|
||||
let cstyle = chr["styleName"].as_str().map(str::to_string);
|
||||
let pdirect = direct_list(¶["direct"]);
|
||||
let cdirect = direct_list(&chr["direct"]);
|
||||
let mut clear: Option<&str> = None;
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
section(ui, &t, "Paragraph", |ui| {
|
||||
if level_row(ui, &t, "Style", &pstyle, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") {
|
||||
clear = Some("paragraphStyle");
|
||||
}
|
||||
let (txt, empty) = if pdirect.is_empty() { (none.clone(), true) } else { (pdirect.join(", "), false) };
|
||||
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Paragraph Formatting", empty) {
|
||||
clear = Some("paragraphFormatting");
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
section(ui, &t, "Characters", |ui| {
|
||||
let (txt, empty) = match &cstyle {
|
||||
Some(n) => (n.clone(), false),
|
||||
None => (none.clone(), true),
|
||||
};
|
||||
if level_row(ui, &t, "Style", &txt, empty, "Clear Character Style", empty) {
|
||||
clear = Some("characterStyle");
|
||||
}
|
||||
let (txt, empty) = if cdirect.is_empty() { (none.clone(), true) } else { (cdirect.join(", "), false) };
|
||||
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Character Formatting", empty) {
|
||||
clear = Some("characterFormatting");
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Clear All")).on_hover_text(tl!("Clear All Formatting")).clicked() {
|
||||
let _ = app.run("format.clear", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
if ui.button(tl!("Styles Pane")).clicked() {
|
||||
let _ = app.run("view.stylesPane", json!({"value": true}));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(level) = clear {
|
||||
let _ = app.run("styles.inspectorClear", json!({"level": level}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled, bordered group in the inspector.
|
||||
fn section(ui: &mut Ui, t: &Tokens, title: &str, body: impl FnOnce(&mut Ui)) {
|
||||
ui.label(egui::RichText::new(tl!(title)).font(semibold(12.5)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
body(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// One level: a caption, its value, and a clear button (disabled when there's nothing to clear).
|
||||
/// Returns true when the button was clicked.
|
||||
fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip: &str, disabled: bool) -> bool {
|
||||
ui.label(egui::RichText::new(tl!(caption)).font(regular(11.0)).color(t.text_dim));
|
||||
let mut clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 26.0).max(40.0);
|
||||
ui.allocate_ui(vec2(w, 18.0), |ui| {
|
||||
ui.set_width(w);
|
||||
let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text });
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click());
|
||||
let enabled = !disabled;
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
crate::icons::paint(
|
||||
ui.painter(),
|
||||
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
|
||||
"clear",
|
||||
c,
|
||||
if enabled { t.accent } else { c },
|
||||
);
|
||||
let resp = resp.on_hover_text(tl!(tip));
|
||||
clicked = enabled && resp.clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Readable descriptions of the inspector's `direct` entries (`[{"prop", "value"}]`).
|
||||
fn direct_list(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"…
|
||||
fn describe(prop: &str, v: &Value) -> String {
|
||||
let label = match prop {
|
||||
"font" => "Font",
|
||||
"size" => "Size",
|
||||
"bold" => "Bold",
|
||||
"italic" => "Italic",
|
||||
"underline" => "Underline",
|
||||
"underlineColor" => "Underline color",
|
||||
"strike" => "Strikethrough",
|
||||
"doubleStrike" => "Double strikethrough",
|
||||
"color" => "Font color",
|
||||
"highlight" => "Highlight",
|
||||
"shading" => "Shading",
|
||||
"vertAlign" => "Position",
|
||||
"caps" => "All caps",
|
||||
"smallCaps" => "Small caps",
|
||||
"hidden" => "Hidden",
|
||||
"spacing" => "Character spacing",
|
||||
"scale" => "Scale",
|
||||
"position" => "Raised/lowered",
|
||||
"outline" => "Outline",
|
||||
"shadow" => "Shadow",
|
||||
"emboss" => "Emboss",
|
||||
"engrave" => "Engrave",
|
||||
"align" => "Alignment",
|
||||
"indentLeft" => "Left indent",
|
||||
"indentRight" => "Right indent",
|
||||
"indentFirst" => "First line",
|
||||
"spaceBefore" => "Space before",
|
||||
"spaceAfter" => "Space after",
|
||||
"lineSpacing" => "Line spacing",
|
||||
"contextualSpacing" => "Don't add space between paragraphs of the same style",
|
||||
"keepNext" => "Keep with next",
|
||||
"keepLines" => "Keep lines together",
|
||||
"pageBreakBefore" => "Page break before",
|
||||
"widowControl" => "Widow/Orphan control",
|
||||
"outlineLevel" => "Outline level",
|
||||
"tabs" => "Tabs",
|
||||
"borders" => "Borders",
|
||||
"suppressHyphens" => "Don't hyphenate",
|
||||
"suppressLineNumbers" => "Suppress line numbers",
|
||||
"bidi" => "Right-to-left",
|
||||
"dropCap" => "Drop cap",
|
||||
other => other,
|
||||
};
|
||||
let label = tl!(label);
|
||||
let pt = |x: f64| crate::i18n::fmt(tl!("{n} pt"), &[("n", &trim_num(x))]);
|
||||
let hex = |c: &Value| -> Option<String> {
|
||||
let a = c.as_array()?;
|
||||
let ch = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255);
|
||||
Some(format!("#{:02X}{:02X}{:02X}", ch(0), ch(1), ch(2)))
|
||||
};
|
||||
let value = match (prop, v) {
|
||||
(_, Value::Bool(true)) => return label.to_string(),
|
||||
(_, Value::Bool(false)) => return crate::i18n::fmt(tl!("Not {name}"), &[("name", label)]),
|
||||
("scale", Value::Number(n)) => format!("{}%", trim_num(n.as_f64().unwrap_or(100.0))),
|
||||
("outlineLevel", Value::Number(n)) => match n.as_u64().unwrap_or(9) {
|
||||
l @ 0..=8 => crate::i18n::fmt(tl!("Level {n}"), &[("n", &(l + 1).to_string())]),
|
||||
_ => tl!("Body Text").to_string(),
|
||||
},
|
||||
("dropCap", Value::Number(n)) => crate::i18n::fmt(tl!("{n} lines"), &[("n", &n.to_string())]),
|
||||
(_, Value::Number(n)) => pt(n.as_f64().unwrap_or(0.0)),
|
||||
("align", Value::String(s)) => tl!(match s.as_str() {
|
||||
"center" => "Centered",
|
||||
"right" => "Right",
|
||||
"justify" => "Justified",
|
||||
"distribute" => "Distributed",
|
||||
_ => "Left",
|
||||
})
|
||||
.to_string(),
|
||||
("color", Value::String(_)) => tl!("Automatic").to_string(),
|
||||
("color", Value::Object(o)) => o.get("Rgb").and_then(hex).unwrap_or_default(),
|
||||
(_, Value::Array(_)) if prop != "tabs" => hex(v).unwrap_or_default(),
|
||||
("lineSpacing", Value::Object(o)) => {
|
||||
let x = o.get("value").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
match o.get("rule").and_then(Value::as_str) {
|
||||
Some("atLeast") => crate::i18n::fmt(tl!("At least {n}"), &[("n", &pt(x))]),
|
||||
Some("exactly") => crate::i18n::fmt(tl!("Exactly {n}"), &[("n", &pt(x))]),
|
||||
_ => crate::i18n::fmt(tl!("{n} lines"), &[("n", &trim_num(x))]),
|
||||
}
|
||||
}
|
||||
(_, Value::String(s)) => tl!(&camel_words(s)).to_string(),
|
||||
_ => return label.to_string(),
|
||||
};
|
||||
format!("{label}: {value}")
|
||||
}
|
||||
|
||||
/// `1.50` → `1.5`, `12.0` → `12`.
|
||||
fn trim_num(x: f64) -> String {
|
||||
let s = format!("{:.2}", if x.is_finite() { x } else { 0.0 });
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// `dotDash` → `Dot dash` (enum values from the document model).
|
||||
fn camel_words(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for (i, c) in s.chars().take(64).enumerate() {
|
||||
if i == 0 {
|
||||
out.extend(c.to_uppercase());
|
||||
} else if c.is_uppercase() {
|
||||
out.push(' ');
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -378,7 +378,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let key = format!("tstyle:{style}:{ppp}");
|
||||
// Keyed by the style sheet too, so a modified table style redraws.
|
||||
let skey = app.previews.styles_key(&app.session.doc, app.session.rev());
|
||||
let key = format!("tstyle:{style}:{skey}:{ppp}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let mut tb = Table::new(5, 4, 100.0);
|
||||
|
||||
@@ -241,11 +241,12 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str
|
||||
fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
group(ui, "Clipboard", None, app, |ui, app| {
|
||||
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
|
||||
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
|
||||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||||
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
|
||||
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
|
||||
@@ -457,7 +458,11 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
group(ui, "Editing", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked();
|
||||
menu_button(ui, app, "find", Some("Find"), "Find", false, |ui, app| {
|
||||
mi(ui, app, "Find", "ui.dialog", json!({"name": "find"}));
|
||||
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
|
||||
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
|
||||
});
|
||||
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
|
||||
menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| {
|
||||
mi(ui, app, "Select All", "select.all", json!({}));
|
||||
@@ -1044,6 +1049,11 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Zoom", None, app, |ui, app| {
|
||||
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
|
||||
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
|
||||
// Step the zoom up and down by 10% (issue #67), from whatever the page shows now.
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false);
|
||||
small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false);
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
|
||||
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
|
||||
@@ -1233,18 +1243,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(String, String)> = app
|
||||
// The document's own styles first, so a new one shows without scrolling.
|
||||
let mut styles: Vec<(bool, String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
styles.sort_by_key(|(builtin, _, _)| *builtin);
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
@@ -1252,6 +1264,32 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
let current = look.and_then(|_| {
|
||||
let (tp, _, _) = app.session.sel.focus.path.cell()?;
|
||||
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
app.session
|
||||
.doc
|
||||
.styles
|
||||
.get(&id)
|
||||
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
|
||||
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
|
||||
});
|
||||
let can_modify = current.is_some();
|
||||
// Built-in table styles can't be deleted.
|
||||
let can_delete = current == Some(false);
|
||||
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
|
||||
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
|
||||
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
if let Err(e) = app.run("table.deleteStyle", json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
@@ -1288,8 +1326,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
mi(ui, app, "Select Row", "table.selectRow", json!({}));
|
||||
mi(ui, app, "Select Table", "table.selectTable", json!({}));
|
||||
});
|
||||
let g = app.session.view.gridlines;
|
||||
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g);
|
||||
let g = app.session.view.table_gridlines;
|
||||
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g);
|
||||
small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! How the document canvas scrolls (issue #122).
|
||||
//!
|
||||
//! egui's built-in wheel handling smooths every delta it thinks is coarse and drops the deltas of
|
||||
//! a touchpad's first and last events, and Wayland compositors leave kinetic scrolling to the app
|
||||
//! (GTK, Qt and browsers synthesise it after the fingers lift). So the canvas scrolls itself:
|
||||
//! - touchpad (pixel) deltas move the page 1:1, with no animation;
|
||||
//! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose
|
||||
//! system doesn't already send momentum events (not macOS, Windows or the web);
|
||||
//! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui.
|
||||
|
||||
use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
|
||||
|
||||
/// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text,
|
||||
/// like other desktop apps (egui's default is 40).
|
||||
pub const NOTCH_PX: f32 = 56.0;
|
||||
|
||||
/// Whether to synthesise kinetic scrolling. macOS and Windows send their own momentum events
|
||||
/// (or report touchpads as line deltas), and browsers do their own; only Linux/BSD need it.
|
||||
const SYNTH_MOMENTUM: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_os = "windows", target_arch = "wasm32"));
|
||||
|
||||
/// Longest frame time trusted for velocity and decay (a stall shouldn't fling the page).
|
||||
const MAX_DT: f32 = 0.1;
|
||||
/// Shortest event spacing trusted for velocity (two events in one burst aren't infinitely fast).
|
||||
const MIN_DT: f32 = 1.0 / 500.0;
|
||||
/// Velocity is averaged over roughly this many seconds of input.
|
||||
const VELOCITY_WINDOW: f32 = 0.04;
|
||||
/// With no "fingers lifted" event, coast after this long without input.
|
||||
const IDLE_RELEASE: f32 = 0.05;
|
||||
/// Fingers that rested this long before lifting don't fling.
|
||||
const STALE: f32 = 0.1;
|
||||
/// Slower flicks than this (points/s) don't coast…
|
||||
const MIN_FLING: f32 = 150.0;
|
||||
/// …and coasting stops below this speed.
|
||||
const STOP_SPEED: f32 = 20.0;
|
||||
/// Fastest coast (points/s).
|
||||
const MAX_SPEED: f32 = 8000.0;
|
||||
/// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s.
|
||||
const DECAY: f32 = 3.0;
|
||||
|
||||
/// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps
|
||||
/// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate
|
||||
/// independent. Deltas are in screen points, positive = content moves down/right.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Momentum {
|
||||
velocity: Vec2,
|
||||
/// Seconds since the last fed delta.
|
||||
since_input: f32,
|
||||
/// Fingers are on the touchpad (deltas arrived and no lift yet).
|
||||
streaming: bool,
|
||||
coasting: bool,
|
||||
/// The device reports when the fingers lift, so don't guess it from a pause.
|
||||
reports_lift: bool,
|
||||
}
|
||||
|
||||
impl Momentum {
|
||||
/// A frame's touchpad delta, `dt` seconds after the previous frame. Non-finite input is ignored.
|
||||
pub fn feed(&mut self, delta: Vec2, dt: f32) {
|
||||
if !delta.is_finite() || !dt.is_finite() {
|
||||
return;
|
||||
}
|
||||
let dt = dt.clamp(0.0, MAX_DT);
|
||||
let fresh = !self.streaming || self.since_input + dt > STALE;
|
||||
let span = if fresh { dt } else { self.since_input + dt }.clamp(MIN_DT, MAX_DT);
|
||||
let sample = delta / span;
|
||||
self.velocity = if fresh {
|
||||
sample
|
||||
} else {
|
||||
let a = 1.0 - (-span / VELOCITY_WINDOW).exp();
|
||||
self.velocity + (sample - self.velocity) * a
|
||||
};
|
||||
let speed = self.velocity.length();
|
||||
if speed > MAX_SPEED {
|
||||
self.velocity *= MAX_SPEED / speed;
|
||||
}
|
||||
self.since_input = 0.0;
|
||||
self.streaming = true;
|
||||
self.coasting = false;
|
||||
}
|
||||
|
||||
/// The fingers lifted: coast if the flick was fast and recent enough.
|
||||
pub fn release(&mut self) {
|
||||
self.reports_lift = true;
|
||||
self.start_coast();
|
||||
}
|
||||
|
||||
fn start_coast(&mut self) {
|
||||
let fling = self.streaming && self.since_input <= STALE && self.velocity.length() >= MIN_FLING;
|
||||
self.streaming = false;
|
||||
if fling {
|
||||
self.coasting = true;
|
||||
} else {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop dead (a click, a key, a wheel notch, the end of the document).
|
||||
pub fn cancel(&mut self) {
|
||||
self.velocity = Vec2::ZERO;
|
||||
self.coasting = false;
|
||||
self.streaming = false;
|
||||
}
|
||||
|
||||
/// Advance a frame without touchpad input; returns how far to coast this frame.
|
||||
pub fn tick(&mut self, dt: f32) -> Vec2 {
|
||||
if !dt.is_finite() || dt <= 0.0 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let dt = dt.min(MAX_DT);
|
||||
self.since_input += dt;
|
||||
if self.streaming && !self.reports_lift && self.since_input >= IDLE_RELEASE {
|
||||
self.start_coast();
|
||||
}
|
||||
if !self.coasting {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
// Exact integral of v·e^(−kt) over the frame, so the distance doesn't depend on frame rate.
|
||||
let keep = (-DECAY * dt).exp();
|
||||
let step = self.velocity * ((1.0 - keep) / DECAY);
|
||||
self.velocity *= keep;
|
||||
if self.velocity.length() < STOP_SPEED {
|
||||
self.cancel();
|
||||
}
|
||||
step
|
||||
}
|
||||
|
||||
/// Still moving or waiting to decide whether to coast: keep repainting.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.coasting || (self.streaming && !self.reports_lift)
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanvasScroll {
|
||||
momentum: Momentum,
|
||||
/// Wheel distance still to scroll, eased out over a few frames.
|
||||
pending: Vec2,
|
||||
}
|
||||
|
||||
impl CanvasScroll {
|
||||
/// This frame's scroll delta in screen points (positive = content moves down/right) from the
|
||||
/// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do
|
||||
/// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height.
|
||||
pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 {
|
||||
let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX };
|
||||
let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 };
|
||||
// Browsers report lines (Firefox: three per notch) where native platforms report notches.
|
||||
let line = if cfg!(target_arch = "wasm32") { notch / 3.0 } else { notch };
|
||||
let dt = input.unstable_dt;
|
||||
let mut now = Vec2::ZERO;
|
||||
let mut touchpad = Vec2::ZERO;
|
||||
let mut lifted = false;
|
||||
let mut stop = false;
|
||||
for event in &input.events {
|
||||
match event {
|
||||
Event::MouseWheel { unit, delta, phase, modifiers } => {
|
||||
// Ctrl/⌘+wheel zooms (egui turns it into a zoom factor).
|
||||
if !hovered || modifiers.matches_any(opts.zoom_modifier) {
|
||||
continue;
|
||||
}
|
||||
let d = axis_lock(if delta.is_finite() { *delta } else { Vec2::ZERO }, *modifiers, opts);
|
||||
match unit {
|
||||
// Touchpads (and precise mice): 1:1. Browsers also report wheel notches in
|
||||
// points, so big jumps there still ease in, like egui does.
|
||||
MouseWheelUnit::Point if !cfg!(target_arch = "wasm32") || d.length() < 8.0 => touchpad += d,
|
||||
MouseWheelUnit::Point => self.pending += d,
|
||||
// Whole notches ease in; fractional ones (X11/Windows touchpads, free-spinning
|
||||
// wheels) are already smooth.
|
||||
MouseWheelUnit::Line if d.x.fract() == 0.0 && d.y.fract() == 0.0 => {
|
||||
stop = true;
|
||||
self.pending += d * line;
|
||||
}
|
||||
MouseWheelUnit::Line => now += d * line,
|
||||
MouseWheelUnit::Page => self.pending += d * page,
|
||||
}
|
||||
lifted |= matches!(phase, TouchPhase::End | TouchPhase::Cancel);
|
||||
}
|
||||
Event::Key { pressed: true, .. } | Event::PointerButton { pressed: true, .. } | Event::Text(_) => stop = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
now += touchpad;
|
||||
let fed = touchpad != Vec2::ZERO;
|
||||
if SYNTH_MOMENTUM {
|
||||
if fed {
|
||||
self.momentum.feed(touchpad, dt);
|
||||
}
|
||||
if lifted {
|
||||
self.momentum.release();
|
||||
}
|
||||
}
|
||||
// A click, key or wheel notch stops a coast (even one that started this frame).
|
||||
if stop {
|
||||
self.momentum.cancel();
|
||||
}
|
||||
if SYNTH_MOMENTUM && !fed {
|
||||
now += self.momentum.tick(dt);
|
||||
}
|
||||
// Ease wheel notches in: 90% of the way in 0.1 s (egui's curve).
|
||||
if self.pending != Vec2::ZERO {
|
||||
let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 };
|
||||
let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t };
|
||||
let step = Vec2::new(ease(self.pending.x), ease(self.pending.y));
|
||||
now += step;
|
||||
self.pending -= step;
|
||||
}
|
||||
now
|
||||
}
|
||||
|
||||
/// The scroll ran into the end of the document: stop coasting and drop queued notches.
|
||||
pub fn hit_edge(&mut self) {
|
||||
self.momentum.cancel();
|
||||
self.pending = Vec2::ZERO;
|
||||
}
|
||||
|
||||
/// Scrolling continues without new input: keep repainting.
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.pending != Vec2::ZERO || self.momentum.is_active()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas.
|
||||
fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 {
|
||||
let horizontal = modifiers.matches_any(opts.horizontal_scroll_modifier);
|
||||
let vertical = modifiers.matches_any(opts.vertical_scroll_modifier);
|
||||
match (horizontal, vertical) {
|
||||
(true, false) => Vec2::new(d.x + d.y, 0.0),
|
||||
(false, true) => Vec2::new(0.0, d.x + d.y),
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but
|
||||
/// gently, so zoomed-out overviews don't crawl and close-ups don't jump.
|
||||
pub fn notch_px(zoom: f32) -> f32 {
|
||||
let z = if zoom.is_finite() && zoom > 0.0 { zoom.sqrt().clamp(0.75, 2.0) } else { 1.0 };
|
||||
NOTCH_PX * z
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FRAME: f32 = 1.0 / 60.0;
|
||||
|
||||
fn flick(m: &mut Momentum) {
|
||||
for _ in 0..6 {
|
||||
m.feed(Vec2::new(0.0, -30.0), FRAME);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flick_coasts_then_decays_to_a_stop() {
|
||||
let mut m = Momentum::default();
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
let first = m.tick(FRAME);
|
||||
assert!(first.y < -10.0, "keeps going the same way after the fingers lift: {first:?}");
|
||||
let mut total = first.y;
|
||||
let mut last = first.y.abs();
|
||||
let mut frames = 0;
|
||||
while m.is_active() {
|
||||
let d = m.tick(FRAME).y;
|
||||
assert!(d.abs() <= last + 1e-3, "slows down");
|
||||
last = d.abs();
|
||||
total += d;
|
||||
frames += 1;
|
||||
assert!(frames < 600, "stops within ten seconds");
|
||||
}
|
||||
// 1800 pt/s with a 1/3 s time constant: a light coast of about 600 pt.
|
||||
assert!((-700.0..-400.0).contains(&total), "coasted {total}");
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO);
|
||||
|
||||
// Frame-rate independent: 144 Hz coasts about as far.
|
||||
let mut fast = Momentum::default();
|
||||
flick(&mut fast);
|
||||
fast.release();
|
||||
let mut total144 = 0.0;
|
||||
while fast.is_active() {
|
||||
total144 += fast.tick(1.0 / 144.0).y;
|
||||
}
|
||||
assert!((total144 - total).abs() < 30.0, "{total144} vs {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_input_cancels_and_slow_or_stale_lifts_dont_fling() {
|
||||
let mut m = Momentum::default();
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
assert_ne!(m.tick(FRAME), Vec2::ZERO);
|
||||
m.cancel();
|
||||
assert!(!m.is_active());
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO);
|
||||
|
||||
// Fingers touching again mid-coast take over: no coasting until they lift again.
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
m.feed(Vec2::new(0.0, -1.0), FRAME);
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO, "the coast stopped");
|
||||
m.release();
|
||||
assert!(!m.is_active(), "a slow drag doesn't fling");
|
||||
|
||||
// Resting the fingers before lifting doesn't fling either.
|
||||
flick(&mut m);
|
||||
for _ in 0..10 {
|
||||
m.tick(FRAME);
|
||||
}
|
||||
m.release();
|
||||
assert!(!m.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_ignored() {
|
||||
let mut m = Momentum::default();
|
||||
m.feed(Vec2::new(f32::NAN, f32::INFINITY), FRAME);
|
||||
m.feed(Vec2::new(0.0, -30.0), f32::NAN);
|
||||
assert!(!m.is_active());
|
||||
flick(&mut m);
|
||||
m.feed(Vec2::new(0.0, f32::NEG_INFINITY), FRAME);
|
||||
m.release();
|
||||
assert_eq!(m.tick(f32::NAN), Vec2::ZERO);
|
||||
assert_eq!(m.tick(f32::INFINITY), Vec2::ZERO);
|
||||
let d = m.tick(FRAME);
|
||||
assert!(d.is_finite() && d.y < 0.0);
|
||||
// A huge delta in a tiny frame is capped.
|
||||
let mut m = Momentum::default();
|
||||
m.feed(Vec2::new(0.0, 1e30), 1e-9);
|
||||
m.release();
|
||||
assert!(m.tick(FRAME).length() <= MAX_SPEED * FRAME);
|
||||
assert!(notch_px(f32::NAN).is_finite() && notch_px(-1.0) == NOTCH_PX);
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ pub struct Tokens {
|
||||
pub ruler_tick: Color32,
|
||||
pub status_bar: Color32,
|
||||
pub selection: Color32,
|
||||
/// Find › Reading Highlight.
|
||||
pub find_highlight: Color32,
|
||||
pub caret: Color32,
|
||||
pub page_shadow: Color32,
|
||||
/// Selection-frame handles (pictures, shapes, text boxes) and their shadow.
|
||||
@@ -140,6 +142,7 @@ impl Tokens {
|
||||
ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||
status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
|
||||
find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
|
||||
handle: Color32::WHITE,
|
||||
@@ -179,6 +182,7 @@ impl Tokens {
|
||||
ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0),
|
||||
status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||
selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
|
||||
find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
|
||||
handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),
|
||||
|
||||
+4
-12
@@ -2,13 +2,13 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**379 of 428 catalog features have commands (88.6%).**
|
||||
**387 of 431 catalog features have commands (89.8%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 20 | 20 |
|
||||
| Quick Access | 4 | 4 |
|
||||
| Home | 93 | 101 |
|
||||
| Home | 97 | 101 |
|
||||
| Insert | 42 | 53 |
|
||||
| Draw | 0 | 11 |
|
||||
| Design | 10 | 10 |
|
||||
@@ -17,26 +17,22 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
| Mailings | 17 | 17 |
|
||||
| Review | 26 | 29 |
|
||||
| View | 25 | 29 |
|
||||
| Table Design | 5 | 5 |
|
||||
| Table Design | 8 | 8 |
|
||||
| Table Layout | 28 | 30 |
|
||||
| Equation | 21 | 22 |
|
||||
| Picture Format | 13 | 13 |
|
||||
| Shape Format | 2 | 6 |
|
||||
| Header & Footer | 5 | 5 |
|
||||
| Editing | 23 | 24 |
|
||||
| Editing | 24 | 24 |
|
||||
| Tools | 3 | 6 |
|
||||
|
||||
## Missing
|
||||
|
||||
### Home
|
||||
|
||||
- Clipboard › Paste Special (`edit.pasteSpecial`)
|
||||
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
|
||||
- Font › Phonetic Guide (`format.phonetic`)
|
||||
- Font › Enclose Characters (`format.enclose`)
|
||||
- Paragraph › Asian Typography (`para.asianTypography`)
|
||||
- Styles › Style Inspector (`styles.inspector`)
|
||||
- Editing › Advanced Find (`edit.advancedFind`)
|
||||
- Voice › Dictate (`tools.dictate`)
|
||||
|
||||
### Insert
|
||||
@@ -100,10 +96,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
- Text › Align Text (`shape.alignText`)
|
||||
- Text › Create Link (`shape.link`)
|
||||
|
||||
### Editing
|
||||
|
||||
- Selection › Column Selection (`select.column`)
|
||||
|
||||
### Tools
|
||||
|
||||
- Customize › Customize Ribbon (`tools.customizeRibbon`)
|
||||
|
||||
Reference in New Issue
Block a user