Clippy/fmt clean workspace, ROADMAP with honest parity estimate; xtask ci green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
72262a2ea9
commit
d95e09ba25
|
|
@ -0,0 +1,80 @@
|
|||
# WordCraft roadmap
|
||||
|
||||
WordCraft aims for complete feature parity with Microsoft Word, then goes further on speed, openness
|
||||
and agent control. This file tracks where we are honestly. Generated numbers come from
|
||||
`cargo xtask parity` (`docs/parity.md`).
|
||||
|
||||
## Where we are (2026-10-07)
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **389** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **354 / 405 (87%)** |
|
||||
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~58%** |
|
||||
| Tests | ~240 (unit, round-trip, fuzz/proptest, MCP acceptance) |
|
||||
| Layout speed (188-page document) | 61 ms cold, **1.4 ms** relayout after an edit |
|
||||
| Code | ~42k lines of Rust in 15 crates |
|
||||
|
||||
Catalog coverage overstates parity: many commands are first versions. The estimate weighs each
|
||||
area by how much of Word's behaviour it reproduces.
|
||||
|
||||
| Area | Status | Parity |
|
||||
|---|---|---|
|
||||
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat | 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% |
|
||||
| Tables (insert, merge/split, styles, borders, header rows, sort, formula) | Good; no row splitting across pages, no drawn tables | 65% |
|
||||
| Page layout (margins, size, orientation, columns, breaks, sections) | Good; columns don't balance, line numbers/page borders/vertical alignment not drawn | 60% |
|
||||
| Headers/footers, page numbers, fields | Good; first/even/odd, link to previous | 70% |
|
||||
| Footnotes/endnotes | Placed and editable; long notes don't continue onto the next page | 60% |
|
||||
| References (TOC, citations APA/MLA/Chicago/IEEE, bibliography, index, figures, cross-refs, TOA) | Working first versions | 60% |
|
||||
| Review (spelling, grammar, thesaurus, comments, track changes, compare, protect) | Good; comments show in a pane, not margin balloons | 65% |
|
||||
| Mailings (mail merge, rules, preview, envelopes, labels) | Working | 70% |
|
||||
| Pictures & shapes (insert, size, crop, recolour, effects, styles, float position) | Text does not yet wrap around floating objects; text boxes don't render their text | 40% |
|
||||
| Draw tab (ink), SmartArt, charts, 3D models, equations editor | Not started / linear equations only | 5% |
|
||||
| File formats: DOCX read/write | Good (Word opens our files); charts/SmartArt/OLE dropped | 75% |
|
||||
| File formats: PDF, ODT, RTF, HTML, Markdown, TXT | Working | 70% |
|
||||
| View modes (print, web, draft, read, focus, zoom, navigation pane) | Working | 70% |
|
||||
| Backstage (new from templates, open, info, export, options) | Working; printing goes through PDF | 55% |
|
||||
| Agent control (CLI, MCP, control channel, macros) | Beyond Word | 100%+ |
|
||||
|
||||
## Estimate to 100%
|
||||
|
||||
**About 140–180 hours of Claude Opus 5.5 wall-clock work** (with parallel agents), in this order:
|
||||
|
||||
1. **Text wrap around floating objects, text box rendering, drop caps** (≈15 h) — biggest visual gap.
|
||||
2. **Layout fidelity**: row splitting across pages, column balancing, line numbers, page borders,
|
||||
vertical alignment, footnote continuation, auto-hyphenation, kerning/ligature options (≈25 h).
|
||||
3. **Comments in margin balloons, track-changes balloons, formatting revisions** (≈12 h).
|
||||
4. **Draw tab / ink, shapes on a canvas, grouping, rotation, z-order** (≈20 h).
|
||||
5. **Charts (own renderer) and SmartArt-style diagrams** (≈20 h).
|
||||
6. **Equation editor (OMML read/write, 2D layout)** (≈15 h).
|
||||
7. **Dialog depth**: every Word dialog with all its options (Font, Paragraph, Tabs, Borders and
|
||||
Shading, Page Setup, Styles, Columns, Index/TOC options, Mail Merge wizard, Options panes) (≈20 h).
|
||||
8. **DOCX fidelity corpus**: round-trip and render a public corpus against Word, fix differences (≈20 h).
|
||||
9. **Native printing, accessibility (screen readers), localisation, RTL/complex scripts** (≈15 h).
|
||||
10. **Packaging and signed releases for every platform** (≈5 h, pipeline copied from PhotoCraft).
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | State |
|
||||
|---|---|---|
|
||||
| M0 | Skeleton + vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | **done** |
|
||||
| M1 | DOCX I/O | **done** (first version) |
|
||||
| M2 | Home tab complete | **done** |
|
||||
| M3 | Insert tab | mostly done (charts/SmartArt/icons/3D missing) |
|
||||
| M4 | Layout + Design tabs | mostly done (rendering gaps above) |
|
||||
| M5 | Tables | mostly done |
|
||||
| M6 | References | first version done |
|
||||
| M7 | Review | mostly done |
|
||||
| M8 | View | mostly done |
|
||||
| M9 | Mailings | done (first version) |
|
||||
| M10 | File/Backstage | mostly done |
|
||||
| M11 | Draw + objects | started |
|
||||
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT) | **done** (first versions) |
|
||||
| M13 | Performance budgets | on track (1.4 ms relayout) |
|
||||
| M14 | 1.0 polish, packaging, signing | not started |
|
||||
|
||||
## Current focus
|
||||
Floating-object text wrap and text boxes; layout fidelity items; README screenshots; release pipeline.
|
||||
|
|
@ -103,7 +103,11 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
if let Some(out) = arg_value(&rest, "--save") {
|
||||
let r = if out.to_ascii_lowercase().ends_with(".png") { s.run("file.exportPng", &json!({"path": out})) } else { s.run("file.save", &json!({"path": out})) };
|
||||
let r = if out.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": out}))
|
||||
} else {
|
||||
s.run("file.save", &json!({"path": out}))
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out}");
|
||||
}
|
||||
|
|
@ -152,8 +156,13 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from("# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n");
|
||||
s += &format!("**{} of {} catalog features have commands ({}%).**\n\n| Tab | Live | Total |\n|---|---|---|\n", p["live"], p["total"], p["percent"]);
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
);
|
||||
s += &format!(
|
||||
"**{} of {} catalog features have commands ({}%).**\n\n| Tab | Live | Total |\n|---|---|---|\n",
|
||||
p["live"], p["total"], p["percent"]
|
||||
);
|
||||
for t in p["tabs"].as_array().cloned().unwrap_or_default() {
|
||||
s += &format!("| {} | {} | {} |\n", t["tab"].as_str().unwrap_or(""), t["live"], t["total"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{WordApp, Inbox, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "odt", "rtf", "txt", "md", "html", "htm", "json"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use std::net::{TcpListener, TcpStream};
|
|||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ pub enum DocError {
|
|||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
/// A block in a story.
|
||||
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Block {
|
||||
|
|
@ -372,7 +373,10 @@ impl Document {
|
|||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t.rows.iter().any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
|
|
@ -413,7 +417,7 @@ impl Document {
|
|||
pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> {
|
||||
let mut bl = self.story(s)?;
|
||||
let parent = path.parent();
|
||||
if parent.len() % 3 != 0 {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
for ch in parent.chunks(3) {
|
||||
|
|
@ -427,7 +431,7 @@ impl Document {
|
|||
pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> {
|
||||
let bad = || DocError::BadPath(path.to_string());
|
||||
let parent: Vec<u32> = path.parent().to_vec();
|
||||
if parent.len() % 3 != 0 {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut bl = self.story_mut(s)?;
|
||||
|
|
|
|||
|
|
@ -132,7 +132,10 @@ fn bullet_level(i: usize, c: char) -> Level {
|
|||
text: c.to_string(),
|
||||
indent: 36.0 * (i as f32 + 1.0),
|
||||
hanging: 18.0,
|
||||
chr: CharProps { font: Some(if c == '•' || c == '▪' || c == '○' { "Arial".into() } else { "Segoe UI Symbol".into() }), ..CharProps::default() },
|
||||
chr: CharProps {
|
||||
font: Some(if c == '•' || c == '▪' || c == '○' { "Arial".into() } else { "Segoe UI Symbol".into() }),
|
||||
..CharProps::default()
|
||||
},
|
||||
..Level::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -204,7 +207,10 @@ impl Numbering {
|
|||
pub fn find_kind(&self, kind: ListKind) -> Option<u32> {
|
||||
let want = levels_for(kind);
|
||||
let first = want.first()?;
|
||||
self.nums.iter().find(|n| self.abstract_of(n.id).and_then(|a| a.levels.first()).is_some_and(|l| l.format == first.format && l.text == first.text)).map(|n| n.id)
|
||||
self.nums
|
||||
.iter()
|
||||
.find(|n| self.abstract_of(n.id).and_then(|a| a.levels.first()).is_some_and(|l| l.format == first.format && l.text == first.text))
|
||||
.map(|n| n.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -225,8 +231,8 @@ impl Counters {
|
|||
let lv = (level as usize).min(8);
|
||||
let def = abs.levels.get(lv)?.clone();
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
if !self.seen.contains_key(&num) {
|
||||
self.seen.insert(num, true);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) {
|
||||
e.insert(true);
|
||||
for (l, start) in &n.start_overrides {
|
||||
if let Some(s) = st.get_mut(*l as usize) {
|
||||
*s = start.saturating_sub(1);
|
||||
|
|
|
|||
|
|
@ -189,7 +189,10 @@ impl InlineObject {
|
|||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }
|
||||
InlineObject::BookmarkStart { .. }
|
||||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
|
|
@ -231,7 +234,12 @@ pub struct Paragraph {
|
|||
|
||||
impl PartialEq for Paragraph {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.text == o.text && self.runs == o.runs && self.props == o.props && self.mark == o.mark && self.objects == o.objects && self.section == o.section
|
||||
self.text == o.text
|
||||
&& self.runs == o.runs
|
||||
&& self.props == o.props
|
||||
&& self.mark == o.mark
|
||||
&& self.objects == o.objects
|
||||
&& self.section == o.section
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +251,15 @@ impl Default for Paragraph {
|
|||
|
||||
impl Paragraph {
|
||||
pub fn new() -> Self {
|
||||
Paragraph { text: String::new(), runs: Vec::new(), props: ParaProps::default(), mark: CharProps::default(), objects: Vec::new(), section: None, rev: next_rev() }
|
||||
Paragraph {
|
||||
text: String::new(),
|
||||
runs: Vec::new(),
|
||||
props: ParaProps::default(),
|
||||
mark: CharProps::default(),
|
||||
objects: Vec::new(),
|
||||
section: None,
|
||||
rev: next_rev(),
|
||||
}
|
||||
}
|
||||
/// A paragraph of plain text in one run.
|
||||
pub fn with_text(text: &str, props: CharProps) -> Self {
|
||||
|
|
@ -608,11 +624,11 @@ impl Paragraph {
|
|||
pub fn word_end(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
let Some(after) = self.text.get(off..) else { return self.text.len() };
|
||||
let mut it = after.char_indices().peekable();
|
||||
let it = after.char_indices().peekable();
|
||||
let mut end = self.text.len();
|
||||
let first_word = after.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_');
|
||||
let mut in_space = false;
|
||||
while let Some((i, c)) = it.next() {
|
||||
for (i, c) in it {
|
||||
let w = c.is_alphanumeric() || c == '_' || c == '\'';
|
||||
if c == ' ' || c == NBSP {
|
||||
in_space = true;
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ const MAX_INDENT: f32 = 1584.0; // 22"
|
|||
impl ResolvedPara {
|
||||
fn from(p: &ParaProps, style: &str) -> ResolvedPara {
|
||||
let clampi = |v: Option<f32>| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT);
|
||||
let mut tabs: Vec<TabStop> = p.tabs.clone().unwrap_or_default().into_iter().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).collect();
|
||||
let mut tabs: Vec<TabStop> =
|
||||
p.tabs.clone().unwrap_or_default().into_iter().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).collect();
|
||||
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
ResolvedPara {
|
||||
style: style.to_string(),
|
||||
|
|
@ -234,7 +235,11 @@ mod tests {
|
|||
#[test]
|
||||
fn hostile_values_clamped() {
|
||||
let s = StyleSheet::builtin();
|
||||
let p = s.resolve_para(&ParaProps { indent_left: Some(f32::INFINITY), line_spacing: Some(LineSpacing::Multiple(f32::NAN)), ..Default::default() });
|
||||
let p = s.resolve_para(&ParaProps {
|
||||
indent_left: Some(f32::INFINITY),
|
||||
line_spacing: Some(LineSpacing::Multiple(f32::NAN)),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(p.indent_left, MAX_INDENT);
|
||||
assert_eq!(p.line_spacing, LineSpacing::Multiple(1.0));
|
||||
let c = s.resolve_char(None, &CharProps { size: Some(-4.0), ..Default::default() });
|
||||
|
|
|
|||
|
|
@ -95,8 +95,21 @@ fn ordinal_suffix(n: u32) -> &'static str {
|
|||
|
||||
/// Lowercase roman numerals (0 → empty; values above 3999 use repeated `m`).
|
||||
pub fn roman(mut n: u32) -> String {
|
||||
const T: [(u32, &str); 13] =
|
||||
[(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")];
|
||||
const T: [(u32, &str); 13] = [
|
||||
(1000, "m"),
|
||||
(900, "cm"),
|
||||
(500, "d"),
|
||||
(400, "cd"),
|
||||
(100, "c"),
|
||||
(90, "xc"),
|
||||
(50, "l"),
|
||||
(40, "xl"),
|
||||
(10, "x"),
|
||||
(9, "ix"),
|
||||
(5, "v"),
|
||||
(4, "iv"),
|
||||
(1, "i"),
|
||||
];
|
||||
let mut s = String::new();
|
||||
n = n.min(50_000);
|
||||
for (v, r) in T {
|
||||
|
|
@ -150,15 +163,15 @@ pub fn cardinal_text(n: u32) -> String {
|
|||
0..=19 => ONES.get(n as usize).copied().unwrap_or("").to_string(),
|
||||
20..=99 => {
|
||||
let t = TENS.get((n / 10) as usize).copied().unwrap_or("");
|
||||
if n % 10 == 0 { t.to_string() } else { format!("{t}-{}", ONES.get((n % 10) as usize).copied().unwrap_or("")) }
|
||||
if n.is_multiple_of(10) { t.to_string() } else { format!("{t}-{}", ONES.get((n % 10) as usize).copied().unwrap_or("")) }
|
||||
}
|
||||
100..=999 => {
|
||||
let h = format!("{} hundred", words(n / 100));
|
||||
if n % 100 == 0 { h } else { format!("{h} {}", words(n % 100)) }
|
||||
if n.is_multiple_of(100) { h } else { format!("{h} {}", words(n % 100)) }
|
||||
}
|
||||
_ => {
|
||||
let th = format!("{} thousand", words(n / 1000));
|
||||
if n % 1000 == 0 { th } else { format!("{th} {}", words(n % 1000)) }
|
||||
if n.is_multiple_of(1000) { th } else { format!("{th} {}", words(n % 1000)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,12 +223,7 @@ impl StyleSheet {
|
|||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
add(c_style(
|
||||
"SubtleReference",
|
||||
"Subtle Reference",
|
||||
31,
|
||||
CharProps { small_caps: Some(true), color: color(Rgb(0x5A, 0x5A, 0x5A)), ..chr() },
|
||||
));
|
||||
add(c_style("SubtleReference", "Subtle Reference", 31, CharProps { small_caps: Some(true), color: color(Rgb(0x5A, 0x5A, 0x5A)), ..chr() }));
|
||||
add(c_style(
|
||||
"IntenseReference",
|
||||
"Intense Reference",
|
||||
|
|
@ -245,7 +240,8 @@ impl StyleSheet {
|
|||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
let mut hl = c_style("Hyperlink", "Hyperlink", 99, CharProps { color: color(Rgb(0x46, 0x78, 0x86)), underline: Some(Underline::Single), ..chr() });
|
||||
let mut hl =
|
||||
c_style("Hyperlink", "Hyperlink", 99, CharProps { color: color(Rgb(0x46, 0x78, 0x86)), underline: Some(Underline::Single), ..chr() });
|
||||
hl.quick = false;
|
||||
add(hl);
|
||||
add(Style {
|
||||
|
|
@ -285,7 +281,8 @@ impl StyleSheet {
|
|||
priority: Some(99),
|
||||
..p_style()
|
||||
});
|
||||
let mut fr = c_style("FootnoteReference", "Footnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
let mut fr =
|
||||
c_style("FootnoteReference", "Footnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
fr.quick = false;
|
||||
add(fr);
|
||||
add(Style {
|
||||
|
|
@ -297,7 +294,8 @@ impl StyleSheet {
|
|||
priority: Some(99),
|
||||
..p_style()
|
||||
});
|
||||
let mut er = c_style("EndnoteReference", "Endnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
let mut er =
|
||||
c_style("EndnoteReference", "Endnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
er.quick = false;
|
||||
add(er);
|
||||
add(Style {
|
||||
|
|
@ -408,36 +406,52 @@ fn table_styles() -> Vec<Style> {
|
|||
];
|
||||
let mut v = vec![
|
||||
Style { id: "TableNormal".into(), name: "Normal Table".into(), kind: StyleKind::Table, hidden: true, builtin: true, ..Default::default() },
|
||||
mk("PlainTable1", "Plain Table 1", TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0xBF, 0xBF, 0xBF)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
mk("GridTable1Light", "Grid Table 1 Light", TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0x99, 0x99, 0x99)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
mk(
|
||||
"PlainTable1",
|
||||
"Plain Table 1",
|
||||
TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0xBF, 0xBF, 0xBF)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
mk(
|
||||
"GridTable1Light",
|
||||
"Grid Table 1 Light",
|
||||
TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0x99, 0x99, 0x99)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
for (name, dark, light) in accents {
|
||||
v.push(mk(&format!("GridTable4Accent{name}"), &format!("Grid Table 4 – {name}"), TableStyleParts {
|
||||
borders: Some(Borders::all(thin(light))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
band_fill: Some(light),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}));
|
||||
v.push(mk(&format!("ListTable3Accent{name}"), &format!("List Table 3 – {name}"), TableStyleParts {
|
||||
borders: Some(Borders::box_(thin(dark))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
total_border_top: Some(thin(dark)),
|
||||
..Default::default()
|
||||
}));
|
||||
v.push(mk(
|
||||
&format!("GridTable4Accent{name}"),
|
||||
&format!("Grid Table 4 – {name}"),
|
||||
TableStyleParts {
|
||||
borders: Some(Borders::all(thin(light))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
band_fill: Some(light),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
v.push(mk(
|
||||
&format!("ListTable3Accent{name}"),
|
||||
&format!("List Table 3 – {name}"),
|
||||
TableStyleParts {
|
||||
borders: Some(Borders::box_(thin(dark))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
total_border_top: Some(thin(dark)),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, word_left)).key("Mod+Left / Alt+Left").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, word_right)).key("Mod+Right / Alt+Right").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, word_right))
|
||||
.key("Mod+Right / Alt+Right")
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("End").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down)).key("Mod+Down / Alt+Down").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.docStart", "Document Start", "Navigation", |s, v| mv(s, v, |s, _| s.doc.start_of(s.sel.focus.story))).key("Mod+Home").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.docEnd", "Document End", "Navigation", |s, v| mv(s, v, |s, _| s.doc.end_of(s.sel.focus.story))).key("Mod+End").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down))
|
||||
.key("Mod+Down / Alt+Down")
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.docStart", "Document Start", "Navigation", |s, v| mv(s, v, |s, _| s.doc.start_of(s.sel.focus.story)))
|
||||
.key("Mod+Home")
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.docEnd", "Document End", "Navigation", |s, v| mv(s, v, |s, _| s.doc.end_of(s.sel.focus.story)))
|
||||
.key("Mod+End")
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.pageUp", "Page Up", "Navigation", |s, v| page(s, v, -1)).key("PageUp").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.pageDown", "Page Down", "Navigation", |s, v| page(s, v, 1)).key("PageDown").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.set", "Set Caret", "Navigation", set).params(r#"{"pos": Pos, "extend"?: bool} | {"page": n, "x": pt, "y": pt}"#).pure(),
|
||||
CommandSpec::new("caret.set", "Set Caret", "Navigation", set)
|
||||
.params(r#"{"pos": Pos, "extend"?: bool} | {"page": n, "x": pt, "y": pt}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.all", "Select All", "Home › Editing › Select", select_all).key("Mod+A").pure(),
|
||||
CommandSpec::new("select.range", "Select Range", "Navigation", select_range).params(r#"{"anchor": Pos, "focus": Pos}"#).pure(),
|
||||
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
|
||||
|
|
@ -274,7 +288,8 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
|||
for (i, _) in p.text.match_indices(needle) {
|
||||
count += 1;
|
||||
if count == n {
|
||||
s.sel = Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
|
||||
s.sel =
|
||||
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
|
||||
return sel_result(s);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,10 +124,28 @@ pub fn cite(src: &Source, style: &str, n: usize, pages: &str) -> String {
|
|||
_ => format!("{} et al.", lasts.first().copied().unwrap_or("")),
|
||||
};
|
||||
match style {
|
||||
"MLA" => if pages.is_empty() { format!("({who})") } else { format!("({who} {pages})") },
|
||||
"Chicago" => if pages.is_empty() { format!("({who} {})", src.year) } else { format!("({who} {}, {pages})", src.year) },
|
||||
"MLA" => {
|
||||
if pages.is_empty() {
|
||||
format!("({who})")
|
||||
} else {
|
||||
format!("({who} {pages})")
|
||||
}
|
||||
}
|
||||
"Chicago" => {
|
||||
if pages.is_empty() {
|
||||
format!("({who} {})", src.year)
|
||||
} else {
|
||||
format!("({who} {}, {pages})", src.year)
|
||||
}
|
||||
}
|
||||
"IEEE" => format!("[{n}]"),
|
||||
_ => if pages.is_empty() { format!("({who}, {})", src.year) } else { format!("({who}, {}, p. {pages})", src.year) },
|
||||
_ => {
|
||||
if pages.is_empty() {
|
||||
format!("({who}, {})", src.year)
|
||||
} else {
|
||||
format!("({who}, {}, p. {pages})", src.year)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +154,16 @@ pub fn entry(src: &Source, style: &str, n: usize) -> String {
|
|||
let au = authors(&src.author);
|
||||
let year = if src.year.is_empty() { "n.d.".to_string() } else { src.year.clone() };
|
||||
let pubinfo = [src.city.as_str(), src.publisher.as_str()].iter().filter(|x| !x.is_empty()).copied().collect::<Vec<_>>().join(": ");
|
||||
let journal = if src.journal.is_empty() { String::new() } else { format!("{}{}{}", src.journal, if src.volume.is_empty() { String::new() } else { format!(", {}", src.volume) }, if src.pages.is_empty() { String::new() } else { format!(", {}", src.pages) }) };
|
||||
let journal = if src.journal.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
src.journal,
|
||||
if src.volume.is_empty() { String::new() } else { format!(", {}", src.volume) },
|
||||
if src.pages.is_empty() { String::new() } else { format!(", {}", src.pages) }
|
||||
)
|
||||
};
|
||||
let url = if src.url.is_empty() { String::new() } else { format!(" {}", src.url) };
|
||||
match style {
|
||||
"MLA" => {
|
||||
|
|
@ -146,10 +173,15 @@ pub fn entry(src: &Source, style: &str, n: usize) -> String {
|
|||
_ => au.first().map(|(l, f)| format!("{l}, {f}, et al. ")).unwrap_or_default(),
|
||||
};
|
||||
let container = if journal.is_empty() { src.publisher.clone() } else { journal };
|
||||
format!("{names}\u{201C}{}.\u{201D} {}{}, {year}.{url}", src.title, container, if container.is_empty() { "" } else { "" })
|
||||
format!("{names}\u{201C}{}.\u{201D} {container}, {year}.{url}", src.title)
|
||||
}
|
||||
"Chicago" => {
|
||||
let names = au.iter().enumerate().map(|(i, (l, f))| if i == 0 { format!("{l}, {f}") } else { format!("{f} {l}") }).collect::<Vec<_>>().join(", and ");
|
||||
let names = au
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (l, f))| if i == 0 { format!("{l}, {f}") } else { format!("{f} {l}") })
|
||||
.collect::<Vec<_>>()
|
||||
.join(", and ");
|
||||
let rest = if journal.is_empty() { pubinfo } else { journal };
|
||||
format!("{names}. {year}. {}. {rest}.{url}", src.title)
|
||||
}
|
||||
|
|
@ -163,7 +195,11 @@ pub fn entry(src: &Source, style: &str, n: usize) -> String {
|
|||
let names = match names.len() {
|
||||
0 => String::new(),
|
||||
1 => names.first().cloned().unwrap_or_default(),
|
||||
_ => format!("{}, & {}", names.get(..names.len() - 1).map(|x| x.join(", ")).unwrap_or_default(), names.last().cloned().unwrap_or_default()),
|
||||
_ => format!(
|
||||
"{}, & {}",
|
||||
names.get(..names.len() - 1).map(|x| x.join(", ")).unwrap_or_default(),
|
||||
names.last().cloned().unwrap_or_default()
|
||||
),
|
||||
};
|
||||
let rest = if journal.is_empty() { src.publisher.clone() } else { journal };
|
||||
format!("{names} ({year}). {}. {rest}.{url}", src.title)
|
||||
|
|
@ -175,7 +211,13 @@ fn sources(s: &mut Session, v: &Value) -> CmdResult {
|
|||
if let Some(a) = v.get("add") {
|
||||
let mut src: Source = serde_json::from_value(a.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
if src.tag.is_empty() {
|
||||
let base: String = authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_else(|| "Source".into()).chars().filter(|c| c.is_alphanumeric()).collect();
|
||||
let base: String = authors(&src.author)
|
||||
.first()
|
||||
.map(|x| x.0.clone())
|
||||
.unwrap_or_else(|| "Source".into())
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect();
|
||||
src.tag = format!("{base}{}", src.year);
|
||||
}
|
||||
s.doc.sources.retain(|x| x.tag != src.tag);
|
||||
|
|
@ -331,11 +373,14 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
|||
.filter_map(|p| s.doc.para(StoryRef::Body, p))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } if instr.trim_start().starts_with("CITATION") => instr.split_whitespace().nth(1).map(str::to_string),
|
||||
InlineObject::Field { instr, .. } if instr.trim_start().starts_with("CITATION") => {
|
||||
instr.split_whitespace().nth(1).map(str::to_string)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut srcs: Vec<(usize, Source)> = s.doc.sources.iter().cloned().enumerate().filter(|(_, x)| cited.is_empty() || cited.contains(&x.tag)).collect();
|
||||
let mut srcs: Vec<(usize, Source)> =
|
||||
s.doc.sources.iter().cloned().enumerate().filter(|(_, x)| cited.is_empty() || cited.contains(&x.tag)).collect();
|
||||
if style != "IEEE" {
|
||||
srcs.sort_by_key(|(_, x)| authors(&x.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| x.title.to_lowercase()));
|
||||
}
|
||||
|
|
@ -454,14 +499,19 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let Some(target) = p::str(v, "target") else {
|
||||
return Ok(Value::Array(targets.iter().map(|(t, _)| json!(t)).collect()));
|
||||
};
|
||||
let (text, pos) = targets.into_iter().find(|(t, _)| t == target || t.starts_with(target)).ok_or_else(|| CmdError::Params(format!("no {to} `{target}`")))?;
|
||||
let (text, pos) =
|
||||
targets.into_iter().find(|(t, _)| t == target || t.starts_with(target)).ok_or_else(|| CmdError::Params(format!("no {to} `{target}`")))?;
|
||||
let show = p::str(v, "show").unwrap_or("text");
|
||||
let page = page_of(s, &pos);
|
||||
let result = match show {
|
||||
"page" => page.to_string(),
|
||||
"number" => text.split(':').next().unwrap_or(&text).trim().to_string(),
|
||||
"aboveBelow" => {
|
||||
if pos < s.sel.focus { "above".into() } else { "below".into() }
|
||||
if pos < s.sel.focus {
|
||||
"above".into()
|
||||
} else {
|
||||
"below".into()
|
||||
}
|
||||
}
|
||||
_ => text.clone(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,12 +9,42 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
|||
|
||||
/// Our own themes: (name, heading font, body font, accent colours).
|
||||
pub const THEMES: &[(&str, &str, &str, [Rgb; 6])] = &[
|
||||
("Craft", styles::HEADING_FONT, styles::BODY_FONT, [Rgb(0x15, 0x60, 0x82), Rgb(0xE9, 0x71, 0x32), Rgb(0x19, 0x6B, 0x24), Rgb(0x0F, 0x9E, 0xD5), Rgb(0xA0, 0x2B, 0x93), Rgb(0x4E, 0xA7, 0x2E)]),
|
||||
("Studio", "Georgia", "Georgia", [Rgb(0x8C, 0x2F, 0x39), Rgb(0xC9, 0x8B, 0x2E), Rgb(0x3E, 0x5C, 0x76), Rgb(0x6B, 0x8F, 0x71), Rgb(0x9A, 0x6F, 0xB0), Rgb(0x4A, 0x4A, 0x4A)]),
|
||||
("Gallery", "Helvetica Neue", "Helvetica Neue", [Rgb(0x22, 0x22, 0x22), Rgb(0xE6, 0x39, 0x46), Rgb(0x45, 0x7B, 0x9D), Rgb(0x1D, 0x35, 0x57), Rgb(0xA8, 0xDA, 0xDC), Rgb(0xF4, 0xA2, 0x61)]),
|
||||
("Atelier", "Palatino", "Palatino", [Rgb(0x6D, 0x59, 0x7A), Rgb(0xB5, 0x65, 0x76), Rgb(0xE5, 0x6B, 0x6F), Rgb(0xEA, 0xAC, 0x8B), Rgb(0x35, 0x50, 0x70), Rgb(0x8E, 0x9A, 0xAF)]),
|
||||
("Darkroom", "Futura", "Avenir Next", [Rgb(0x26, 0x46, 0x53), Rgb(0x2A, 0x9D, 0x8F), Rgb(0xE9, 0xC4, 0x6A), Rgb(0xF4, 0xA2, 0x61), Rgb(0xE7, 0x6F, 0x51), Rgb(0x5E, 0x54, 0x8E)]),
|
||||
("Sketchbook", "Gill Sans", "Gill Sans", [Rgb(0x58, 0x81, 0x57), Rgb(0x3A, 0x5A, 0x40), Rgb(0xA3, 0xB1, 0x8A), Rgb(0xDA, 0xD7, 0xCD), Rgb(0x34, 0x4E, 0x41), Rgb(0xBC, 0x6C, 0x25)]),
|
||||
(
|
||||
"Craft",
|
||||
styles::HEADING_FONT,
|
||||
styles::BODY_FONT,
|
||||
[Rgb(0x15, 0x60, 0x82), Rgb(0xE9, 0x71, 0x32), Rgb(0x19, 0x6B, 0x24), Rgb(0x0F, 0x9E, 0xD5), Rgb(0xA0, 0x2B, 0x93), Rgb(0x4E, 0xA7, 0x2E)],
|
||||
),
|
||||
(
|
||||
"Studio",
|
||||
"Georgia",
|
||||
"Georgia",
|
||||
[Rgb(0x8C, 0x2F, 0x39), Rgb(0xC9, 0x8B, 0x2E), Rgb(0x3E, 0x5C, 0x76), Rgb(0x6B, 0x8F, 0x71), Rgb(0x9A, 0x6F, 0xB0), Rgb(0x4A, 0x4A, 0x4A)],
|
||||
),
|
||||
(
|
||||
"Gallery",
|
||||
"Helvetica Neue",
|
||||
"Helvetica Neue",
|
||||
[Rgb(0x22, 0x22, 0x22), Rgb(0xE6, 0x39, 0x46), Rgb(0x45, 0x7B, 0x9D), Rgb(0x1D, 0x35, 0x57), Rgb(0xA8, 0xDA, 0xDC), Rgb(0xF4, 0xA2, 0x61)],
|
||||
),
|
||||
(
|
||||
"Atelier",
|
||||
"Palatino",
|
||||
"Palatino",
|
||||
[Rgb(0x6D, 0x59, 0x7A), Rgb(0xB5, 0x65, 0x76), Rgb(0xE5, 0x6B, 0x6F), Rgb(0xEA, 0xAC, 0x8B), Rgb(0x35, 0x50, 0x70), Rgb(0x8E, 0x9A, 0xAF)],
|
||||
),
|
||||
(
|
||||
"Darkroom",
|
||||
"Futura",
|
||||
"Avenir Next",
|
||||
[Rgb(0x26, 0x46, 0x53), Rgb(0x2A, 0x9D, 0x8F), Rgb(0xE9, 0xC4, 0x6A), Rgb(0xF4, 0xA2, 0x61), Rgb(0xE7, 0x6F, 0x51), Rgb(0x5E, 0x54, 0x8E)],
|
||||
),
|
||||
(
|
||||
"Sketchbook",
|
||||
"Gill Sans",
|
||||
"Gill Sans",
|
||||
[Rgb(0x58, 0x81, 0x57), Rgb(0x3A, 0x5A, 0x40), Rgb(0xA3, 0xB1, 0x8A), Rgb(0xDA, 0xD7, 0xCD), Rgb(0x34, 0x4E, 0x41), Rgb(0xBC, 0x6C, 0x25)],
|
||||
),
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -33,10 +63,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
set_accent(s, t.3[0]);
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("design.styleSet", "Style Set", "Design › Document Formatting", style_set).params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
|
||||
CommandSpec::new("design.styleSet", "Style Set", "Design › Document Formatting", style_set)
|
||||
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
|
||||
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
|
||||
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark).params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
|
||||
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
|
||||
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
|
||||
sel_result(s)
|
||||
|
|
@ -57,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("design.themes", "Theme List", "Design › Document Formatting", |_, _| Ok(json!(THEMES.iter().map(|t| t.0).collect::<Vec<_>>()))).pure(),
|
||||
CommandSpec::new("design.themes", "Theme List", "Design › Document Formatting", |_, _| {
|
||||
Ok(json!(THEMES.iter().map(|t| t.0).collect::<Vec<_>>()))
|
||||
})
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +113,11 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
|||
|
||||
fn set_accent(s: &mut Session, accent: Rgb) {
|
||||
for st in &mut s.doc.styles.styles {
|
||||
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5') || st.id == "IntenseQuote" || st.id == "IntenseEmphasis" || st.id == "IntenseReference" {
|
||||
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|
||||
|| st.id == "IntenseQuote"
|
||||
|| st.id == "IntenseEmphasis"
|
||||
|| st.id == "IntenseReference"
|
||||
{
|
||||
st.chr.color = Some(TextColor::Rgb(accent));
|
||||
if let Some(b) = st.para.borders.as_mut() {
|
||||
for e in [&mut b.top, &mut b.bottom].into_iter().flatten() {
|
||||
|
|
|
|||
|
|
@ -28,14 +28,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#).key("Mod+Shift+Alt+V"),
|
||||
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
|
||||
.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.find", "Find", "Home › Editing", find).key("Mod+F").params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#).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.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 / Shift+F3").pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all).params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto).key("Mod+Alt+G / F5").params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#).pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
painter_pick(s);
|
||||
|
|
@ -109,7 +118,11 @@ fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
|||
}
|
||||
let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) };
|
||||
let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat };
|
||||
let re = regex::RegexBuilder::new(&pat).case_insensitive(!f.match_case).size_limit(1 << 20).build().map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
|
||||
let re = regex::RegexBuilder::new(&pat)
|
||||
.case_insensitive(!f.match_case)
|
||||
.size_limit(1 << 20)
|
||||
.build()
|
||||
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.params(r#"{"path": string}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png).params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#).pure(),
|
||||
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
|
||||
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "print"}));
|
||||
sel_result(s)
|
||||
|
|
@ -33,7 +35,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.key("Mod+W")
|
||||
.pure(),
|
||||
CommandSpec::new("file.properties", "Properties", "File › Info", properties).params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?}"#),
|
||||
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?}"#),
|
||||
CommandSpec::new("file.info", "Info", "File", info).pure(),
|
||||
CommandSpec::new("file.options", "Options", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "options"}));
|
||||
|
|
@ -196,7 +199,12 @@ fn inspect(s: &mut Session, v: &Value) -> CmdResult {
|
|||
if !p.props.is_empty() {
|
||||
o["props"] = serde_json::to_value(&p.props).unwrap_or(Value::Null);
|
||||
}
|
||||
let runs: Vec<Value> = p.run_ranges().filter(|(_, c)| !c.is_empty()).map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c})).take(50).collect();
|
||||
let runs: Vec<Value> = p
|
||||
.run_ranges()
|
||||
.filter(|(_, c)| !c.is_empty())
|
||||
.map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c}))
|
||||
.take(50)
|
||||
.collect();
|
||||
if !runs.is_empty() {
|
||||
o["runs"] = json!(runs);
|
||||
}
|
||||
|
|
@ -209,13 +217,23 @@ fn inspect(s: &mut Session, v: &Value) -> CmdResult {
|
|||
blocks.push(o);
|
||||
}
|
||||
Block::Table(t) => {
|
||||
let cells: Vec<Vec<String>> = t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")).collect()).collect();
|
||||
let cells: Vec<Vec<String>> = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
blocks.push(json!({"index": i, "type": "table", "rows": t.rows.len(), "cols": t.cols(), "style": t.props.style, "cells": cells}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = s.layout();
|
||||
let parts: Vec<Value> = s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();
|
||||
let parts: Vec<Value> =
|
||||
s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();
|
||||
Ok(json!({
|
||||
"blocks": blocks,
|
||||
"parts": parts,
|
||||
|
|
|
|||
|
|
@ -13,30 +13,57 @@ pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| toggle(s, v, |r| r.bold, |c, on| c.bold = Some(on))).key("Mod+B").params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| toggle(s, v, |r| r.italic, |c, on| c.italic = Some(on))).key("Mod+I").params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.underline", "Underline", "Home › Font", underline).key("Mod+U").params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#),
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| toggle(s, v, |r| r.bold, |c, on| c.bold = Some(on)))
|
||||
.key("Mod+B")
|
||||
.params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| toggle(s, v, |r| r.italic, |c, on| c.italic = Some(on)))
|
||||
.key("Mod+I")
|
||||
.params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.underline", "Underline", "Home › Font", underline)
|
||||
.key("Mod+U")
|
||||
.params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#),
|
||||
CommandSpec::new("format.doubleUnderline", "Double Underline", "Home › Font › Underline", |s, _| {
|
||||
underline(s, &json!({"style": "double"}))
|
||||
})
|
||||
.key("Mod+Shift+D"),
|
||||
CommandSpec::new("format.wordUnderline", "Underline Words Only", "Home › Font › Underline", |s, _| underline(s, &json!({"style": "words"}))).key("Mod+Shift+W"),
|
||||
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on))).params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.wordUnderline", "Underline Words Only", "Home › Font › Underline", |s, _| {
|
||||
underline(s, &json!({"style": "words"}))
|
||||
})
|
||||
.key("Mod+Shift+W"),
|
||||
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on)))
|
||||
.params(r#"{"value"?: bool}"#),
|
||||
CommandSpec::new("format.doubleStrikethrough", "Double Strikethrough", "Home › Font › Font", |s, v| {
|
||||
toggle(s, v, |r| r.double_strike, |c, on| c.double_strike = Some(on))
|
||||
}),
|
||||
CommandSpec::new("format.subscript", "Subscript", "Home › Font", |s, v| {
|
||||
toggle(s, v, |r| r.vert_align == VertAlign::Subscript, |c, on| c.vert_align = Some(if on { VertAlign::Subscript } else { VertAlign::Baseline }))
|
||||
toggle(
|
||||
s,
|
||||
v,
|
||||
|r| r.vert_align == VertAlign::Subscript,
|
||||
|c, on| c.vert_align = Some(if on { VertAlign::Subscript } else { VertAlign::Baseline }),
|
||||
)
|
||||
})
|
||||
.key("Mod+="),
|
||||
CommandSpec::new("format.superscript", "Superscript", "Home › Font", |s, v| {
|
||||
toggle(s, v, |r| r.vert_align == VertAlign::Superscript, |c, on| c.vert_align = Some(if on { VertAlign::Superscript } else { VertAlign::Baseline }))
|
||||
toggle(
|
||||
s,
|
||||
v,
|
||||
|r| r.vert_align == VertAlign::Superscript,
|
||||
|c, on| c.vert_align = Some(if on { VertAlign::Superscript } else { VertAlign::Baseline }),
|
||||
)
|
||||
})
|
||||
.key("Mod+Shift+="),
|
||||
CommandSpec::new("format.allCaps", "All Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.caps, |c, on| c.caps = Some(on))).key("Mod+Shift+A"),
|
||||
CommandSpec::new("format.smallCaps", "Small Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.small_caps, |c, on| c.small_caps = Some(on))).key("Mod+Shift+K"),
|
||||
CommandSpec::new("format.hidden", "Hidden", "Home › Font › Font", |s, v| toggle(s, v, |r| r.hidden, |c, on| c.hidden = Some(on))).key("Mod+Shift+H"),
|
||||
CommandSpec::new("format.outline", "Outline", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.outline, |c, on| c.outline = Some(on))),
|
||||
CommandSpec::new("format.allCaps", "All Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.caps, |c, on| c.caps = Some(on)))
|
||||
.key("Mod+Shift+A"),
|
||||
CommandSpec::new("format.smallCaps", "Small Caps", "Home › Font › Font", |s, v| {
|
||||
toggle(s, v, |r| r.small_caps, |c, on| c.small_caps = Some(on))
|
||||
})
|
||||
.key("Mod+Shift+K"),
|
||||
CommandSpec::new("format.hidden", "Hidden", "Home › Font › Font", |s, v| toggle(s, v, |r| r.hidden, |c, on| c.hidden = Some(on)))
|
||||
.key("Mod+Shift+H"),
|
||||
CommandSpec::new("format.outline", "Outline", "Home › Font › Text Effects", |s, v| {
|
||||
toggle(s, v, |r| r.outline, |c, on| c.outline = Some(on))
|
||||
}),
|
||||
CommandSpec::new("format.shadow", "Shadow", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.shadow, |c, on| c.shadow = Some(on))),
|
||||
CommandSpec::new("format.emboss", "Emboss", "Home › Font › Font", |s, v| toggle(s, v, |r| r.emboss, |c, on| c.emboss = Some(on))),
|
||||
CommandSpec::new("format.engrave", "Engrave", "Home › Font › Font", |s, v| toggle(s, v, |r| r.engrave, |c, on| c.engrave = Some(on))),
|
||||
|
|
@ -47,13 +74,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
|
||||
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
|
||||
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight).params(r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight).params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
),
|
||||
CommandSpec::new("format.shading", "Character Shading", "Home › Font", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
apply(s, &|x| x.shading = c)
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null}"#),
|
||||
CommandSpec::new("format.changeCase", "Change Case", "Home › Font", change_case).key("Shift+F3").params(r#"{"mode"?: "sentence|lower|upper|title|toggle"}"#),
|
||||
CommandSpec::new("format.changeCase", "Change Case", "Home › Font", change_case)
|
||||
.key("Shift+F3")
|
||||
.params(r#"{"mode"?: "sentence|lower|upper|title|toggle"}"#),
|
||||
CommandSpec::new("format.clear", "Clear All Formatting", "Home › Font", clear).key("Mod+Space"),
|
||||
CommandSpec::new("format.spacing", "Character Spacing", "Home › Font › Font › Advanced", |s, v| {
|
||||
let x = p::req_f32(v, "points")?.clamp(-100.0, 100.0);
|
||||
|
|
@ -227,7 +258,11 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let h = Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| h.ooxml().eq_ignore_ascii_case(c) || h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], "")) || format!("{h:?}").eq_ignore_ascii_case(c))
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
|
||||
apply(s, &|x| x.highlight = Some(h))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,9 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fn insert_block_at_caret(s: &mut Session, block: Block) -> Result<Path, CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
let len = s.doc.para_at(&at).map(|p| p.len()).unwrap_or(0);
|
||||
let insert_at = if at.off == 0 && len > 0 {
|
||||
at.path.clone()
|
||||
} else if at.off >= len && len == 0 {
|
||||
let insert_at = if at.off == 0 {
|
||||
at.path.clone()
|
||||
} else {
|
||||
let new = s.doc.split_paragraph(&at)?;
|
||||
|
|
@ -180,7 +178,8 @@ pub fn base64_encode(b: &[u8]) -> String {
|
|||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||
for ch in b.chunks(3) {
|
||||
let n = (ch.first().copied().unwrap_or(0) as u32) << 16 | (ch.get(1).copied().unwrap_or(0) as u32) << 8 | ch.get(2).copied().unwrap_or(0) as u32;
|
||||
let n =
|
||||
(ch.first().copied().unwrap_or(0) as u32) << 16 | (ch.get(1).copied().unwrap_or(0) as u32) << 8 | ch.get(2).copied().unwrap_or(0) as u32;
|
||||
for i in 0..4 {
|
||||
if i <= ch.len() {
|
||||
out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
|
|
@ -212,7 +211,8 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
|||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
let (pw, ph) = wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
|
||||
let (pw, ph) =
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
|
|
@ -236,14 +236,16 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj = InlineObject::Image { media: key.clone(), w, h, alt: p::str(v, "alt").unwrap_or("").to_string(), float: Float::default(), crop: [0.0; 4] };
|
||||
let obj =
|
||||
InlineObject::Image { media: key.clone(), w, h, alt: p::str(v, "alt").unwrap_or("").to_string(), float: Float::default(), crop: [0.0; 4] };
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
Ok(json!({"media": key, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let kind: ShapeKind =
|
||||
serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let w = p::f32(v, "width").unwrap_or(108.0).clamp(4.0, 2000.0);
|
||||
let h = p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(1.0, 2000.0);
|
||||
let fill = match p::str(v, "fill") {
|
||||
|
|
@ -254,7 +256,8 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj = InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
|
|
@ -267,7 +270,16 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]);
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill: Some(Rgb::WHITE), stroke: Some(Rgb::BLACK), stroke_width: 0.75, float: Float::default(), story: Some(id) };
|
||||
let obj = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: Some(Rgb::WHITE),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
};
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"story": id}))
|
||||
|
|
@ -403,7 +415,11 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
|||
para.insert_text(0, "Page of ", &CharProps::default())?;
|
||||
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
let end = para.len();
|
||||
para.insert_object(end, InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false }, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
para.insert_object(0, field, &CharProps::default())?;
|
||||
}
|
||||
|
|
@ -433,7 +449,8 @@ pub fn format_date(fmt: &str) -> String {
|
|||
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||||
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
|
||||
let days_since = {
|
||||
// Days since epoch for weekday.
|
||||
|
|
@ -473,7 +490,7 @@ pub fn format_date(fmt: &str) -> String {
|
|||
('d', 1) => out.push_str(&d.to_string()),
|
||||
('H', 2..) => out.push_str(&format!("{hh:02}")),
|
||||
('H', _) => out.push_str(&hh.to_string()),
|
||||
('h', _) => out.push_str(&(if hh % 12 == 0 { 12 } else { hh % 12 }).to_string()),
|
||||
('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()),
|
||||
('m', 2..) => out.push_str(&format!("{mm:02}")),
|
||||
('m', _) => out.push_str(&mm.to_string()),
|
||||
('a' | 'A', _) => out.push_str(if hh < 12 { "AM" } else { "PM" }),
|
||||
|
|
@ -511,7 +528,8 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
|||
for _ in 0..8 {
|
||||
blocks.push(Block::Para(Paragraph::new()));
|
||||
}
|
||||
let mut t = Paragraph::with_text(&title, CharProps { size: Some(44.0), color: Some(TextColor::Rgb(accent)), ..Default::default() }).styled("Title");
|
||||
let mut t =
|
||||
Paragraph::with_text(&title, CharProps { size: Some(44.0), color: Some(TextColor::Rgb(accent)), ..Default::default() }).styled("Title");
|
||||
t.props.borders = Some(wordcraft_doc::props::Borders {
|
||||
bottom: Some(wordcraft_doc::props::Border { style: wordcraft_doc::props::BorderStyle::Single, width: 2.0, color: Some(accent), space: 6.0 }),
|
||||
..Default::default()
|
||||
|
|
@ -521,7 +539,8 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
|||
for _ in 0..14 {
|
||||
blocks.push(Block::Para(Paragraph::new()));
|
||||
}
|
||||
blocks.push(Block::Para(Paragraph::with_text(&author, CharProps { bold: Some(true), color: Some(TextColor::Rgb(accent)), ..Default::default() })));
|
||||
blocks
|
||||
.push(Block::Para(Paragraph::with_text(&author, CharProps { bold: Some(true), color: Some(TextColor::Rgb(accent)), ..Default::default() })));
|
||||
let mut date = Paragraph::with_text(&format_date("MMMM d, yyyy"), CharProps::default());
|
||||
date.insert_text(date.len(), "\u{000C}", &CharProps::default())?;
|
||||
blocks.push(Block::Para(date));
|
||||
|
|
|
|||
|
|
@ -50,12 +50,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
|
||||
})
|
||||
.params(r#"{"field": string}"#),
|
||||
CommandSpec::new("mailings.addressBlock", "Address Block", "Mailings › Write & Insert Fields", |s, _| insert_field(s, "ADDRESSBLOCK", "«AddressBlock»")),
|
||||
CommandSpec::new("mailings.greetingLine", "Greeting Line", "Mailings › Write & Insert Fields", |s, _| insert_field(s, "GREETINGLINE", "«GreetingLine»")),
|
||||
CommandSpec::new("mailings.addressBlock", "Address Block", "Mailings › Write & Insert Fields", |s, _| {
|
||||
insert_field(s, "ADDRESSBLOCK", "«AddressBlock»")
|
||||
}),
|
||||
CommandSpec::new("mailings.greetingLine", "Greeting Line", "Mailings › Write & Insert Fields", |s, _| {
|
||||
insert_field(s, "GREETINGLINE", "«GreetingLine»")
|
||||
}),
|
||||
CommandSpec::new("mailings.rules", "Rules", "Mailings › Write & Insert Fields", |s, v| {
|
||||
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
|
||||
let instr = match rule.as_str() {
|
||||
"IF" => format!("IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"", p::str(v, "field").unwrap_or("Field"), p::str(v, "value").unwrap_or(""), p::str(v, "then").unwrap_or(""), p::str(v, "else").unwrap_or("")),
|
||||
"IF" => format!(
|
||||
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
|
||||
p::str(v, "field").unwrap_or("Field"),
|
||||
p::str(v, "value").unwrap_or(""),
|
||||
p::str(v, "then").unwrap_or(""),
|
||||
p::str(v, "else").unwrap_or("")
|
||||
),
|
||||
"SKIPIF" => format!("SKIPIF {{ MERGEFIELD {} }} = \"{}\"", p::str(v, "field").unwrap_or("Field"), p::str(v, "value").unwrap_or("")),
|
||||
"MERGEREC" => "MERGEREC".into(),
|
||||
_ => "NEXT".into(),
|
||||
|
|
@ -82,7 +92,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients),
|
||||
CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| {
|
||||
let q = p::req_str(v, "text")?.to_lowercase();
|
||||
let idx = s.merge.rows.iter().position(|r| r.iter().any(|c| c.to_lowercase().contains(&q))).ok_or_else(|| CmdError::Failed("no matching recipient".into()))?;
|
||||
let idx = s
|
||||
.merge
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| r.iter().any(|c| c.to_lowercase().contains(&q)))
|
||||
.ok_or_else(|| CmdError::Failed("no matching recipient".into()))?;
|
||||
s.merge.record = idx;
|
||||
s.merge.preview = true;
|
||||
refresh(s);
|
||||
|
|
@ -99,9 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"ok": missing.is_empty(), "unknownFields": missing, "records": s.merge.rows.len()}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish).params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#).when(has_recipients),
|
||||
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params(r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|Envelope DL"}"#),
|
||||
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels).params(r#"{"text"?: string, "rows"?: n, "cols"?: n, "fromRecipients"?: bool}"#),
|
||||
CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish)
|
||||
.params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#)
|
||||
.when(has_recipients),
|
||||
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes)
|
||||
.params(r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|Envelope DL"}"#),
|
||||
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels)
|
||||
.params(r#"{"text"?: string, "rows"?: n, "cols"?: n, "fromRecipients"?: bool}"#),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +200,12 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
let rows = arr
|
||||
.iter()
|
||||
.map(|r| headers.iter().map(|h| r.get(h).map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).unwrap_or_default()).collect())
|
||||
.map(|r| {
|
||||
headers
|
||||
.iter()
|
||||
.map(|h| r.get(h).map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).unwrap_or_default())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(headers, rows)
|
||||
} else {
|
||||
|
|
@ -272,7 +296,11 @@ fn address_block(s: &Session, row: usize) -> String {
|
|||
let m = address_map(&s.merge.headers);
|
||||
let g = |k: &str| m.get(k).and_then(Value::as_str).map(|f| value(s, row, f)).unwrap_or_default();
|
||||
let name = [g("title"), g("firstName"), g("lastName")].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(" ");
|
||||
let city_line = [g("city"), [g("state"), g("postal")].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(" ")].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(", ");
|
||||
let city_line = [g("city"), [g("state"), g("postal")].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join(" ")]
|
||||
.into_iter()
|
||||
.filter(|x| !x.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
[name, g("company"), g("address"), city_line, g("country")].into_iter().filter(|x| !x.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
|
|
@ -297,9 +325,11 @@ fn eval(s: &Session, instr: &str, row: usize) -> Option<String> {
|
|||
"NEXT" | "SKIPIF" => Some(String::new()),
|
||||
"IF" => {
|
||||
// IF { MERGEFIELD X } = "v" "then" "else"
|
||||
let field = t.split("MERGEFIELD").nth(1).and_then(|r| r.split('}').next()).map(|x| x.trim().trim_matches('"').to_string()).unwrap_or_default();
|
||||
let field =
|
||||
t.split("MERGEFIELD").nth(1).and_then(|r| r.split('}').next()).map(|x| x.trim().trim_matches('"').to_string()).unwrap_or_default();
|
||||
let quoted: Vec<&str> = t.split('"').skip(1).step_by(2).collect();
|
||||
let (cmp, then, els) = (quoted.first().copied().unwrap_or(""), quoted.get(1).copied().unwrap_or(""), quoted.get(2).copied().unwrap_or(""));
|
||||
let (cmp, then, els) =
|
||||
(quoted.first().copied().unwrap_or(""), quoted.get(1).copied().unwrap_or(""), quoted.get(2).copied().unwrap_or(""));
|
||||
Some(if value(s, row, &field) == cmp { then.to_string() } else { els.to_string() })
|
||||
}
|
||||
_ => None,
|
||||
|
|
@ -417,14 +447,25 @@ fn merge_block(s: &Session, b: &mut Block, row: usize) {
|
|||
}
|
||||
|
||||
fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) });
|
||||
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| {
|
||||
if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) }
|
||||
});
|
||||
let ret = p::str(v, "return").unwrap_or("").to_string();
|
||||
let (w, h) = match p::str(v, "size").unwrap_or("Envelope #10") {
|
||||
"Envelope DL" => (623.6, 311.8),
|
||||
_ => (684.0, 297.0),
|
||||
};
|
||||
let mut d = Document::new();
|
||||
d.last_section = SectionProps { page_w: w, page_h: h, landscape: true, margin_top: 22.0, margin_left: 22.0, margin_right: 22.0, margin_bottom: 22.0, ..Default::default() };
|
||||
d.last_section = SectionProps {
|
||||
page_w: w,
|
||||
page_h: h,
|
||||
landscape: true,
|
||||
margin_top: 22.0,
|
||||
margin_left: 22.0,
|
||||
margin_right: 22.0,
|
||||
margin_bottom: 22.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut blocks = Vec::new();
|
||||
for l in ret.split('\n') {
|
||||
blocks.push(para_block(Paragraph::with_text(l, CharProps { size: Some(10.0), ..Default::default() }).styled("NoSpacing")));
|
||||
|
|
|
|||
|
|
@ -293,7 +293,11 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
|||
for path in paths {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
let inside = if a == b { off + wordcraft_doc::para::OBJ.len_utf8() == a.off || off == a.off } else { (path != a.path || off >= a.off) && (path != b.path || off < b.off) };
|
||||
let inside = if a == b {
|
||||
off + wordcraft_doc::para::OBJ.len_utf8() == a.off || off == a.off
|
||||
} else {
|
||||
(path != a.path || off >= a.off) && (path != b.path || off < b.off)
|
||||
};
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -408,7 +412,11 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
|
|||
let l = 0.299 * rf + 0.587 * gf + 0.114 * bf;
|
||||
let out = match mode {
|
||||
"grayscale" => [l, l, l],
|
||||
"sepia" => [(0.393 * rf + 0.769 * gf + 0.189 * bf).min(255.0), (0.349 * rf + 0.686 * gf + 0.168 * bf).min(255.0), (0.272 * rf + 0.534 * gf + 0.131 * bf).min(255.0)],
|
||||
"sepia" => [
|
||||
(0.393 * rf + 0.769 * gf + 0.189 * bf).min(255.0),
|
||||
(0.349 * rf + 0.686 * gf + 0.168 * bf).min(255.0),
|
||||
(0.272 * rf + 0.534 * gf + 0.131 * bf).min(255.0),
|
||||
],
|
||||
"washout" => [rf * 0.35 + 255.0 * 0.65, gf * 0.35 + 255.0 * 0.65, bf * 0.35 + 255.0 * 0.65],
|
||||
"blackAndWhite" => {
|
||||
let v = if l > 127.0 { 255.0 } else { 0.0 };
|
||||
|
|
@ -555,7 +563,9 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| if x > 2 && x < w - 3 && y > 2 && y < h - 3 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) });
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
if x > 2 && x < w - 3 && y > 2 && y < h - 3 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||||
});
|
||||
let mut b = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
|
||||
b
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins)
|
||||
.params(r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt}"#),
|
||||
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params(
|
||||
r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt}"#,
|
||||
),
|
||||
CommandSpec::new("layout.orientation", "Orientation", "Layout › Page Setup", |s, v| {
|
||||
let land = match p::str(v, "value") {
|
||||
Some("landscape") => true,
|
||||
|
|
@ -21,8 +22,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.params(r#"{"value": "portrait|landscape"}"#),
|
||||
CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt}"#),
|
||||
CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns).params(r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right"}"#),
|
||||
CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks).params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#),
|
||||
CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns)
|
||||
.params(r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right"}"#),
|
||||
CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks)
|
||||
.params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#),
|
||||
CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", |s, v| {
|
||||
let mode = p::str(v, "value").unwrap_or("continuous");
|
||||
with_sect(s, |x| {
|
||||
|
|
@ -76,7 +79,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
})
|
||||
.params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#),
|
||||
CommandSpec::new("layout.section", "Section Properties", "Layout › Page Setup", |s, _| Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))).pure(),
|
||||
CommandSpec::new("layout.section", "Section Properties", "Layout › Page Setup", |s, _| {
|
||||
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
|
||||
})
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +148,10 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
|
|||
|
||||
fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (w, h) = if let Some(n) = p::str(v, "name") {
|
||||
let (_, w, h) = wordcraft_geom::PAPER_SIZES.iter().find(|(name, _, _)| name.eq_ignore_ascii_case(n)).ok_or_else(|| CmdError::Params(format!("unknown paper `{n}`")))?;
|
||||
let (_, w, h) = wordcraft_geom::PAPER_SIZES
|
||||
.iter()
|
||||
.find(|(name, _, _)| name.eq_ignore_ascii_case(n))
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown paper `{n}`")))?;
|
||||
(*w, *h)
|
||||
} else {
|
||||
(p::req_f32(v, "width")?, p::req_f32(v, "height")?)
|
||||
|
|
@ -182,7 +191,9 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let start = match kind {
|
||||
"page" => return super::text::specs().iter().find(|c| c.id == "text.pageBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
|
||||
"column" => return super::text::specs().iter().find(|c| c.id == "text.columnBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
|
||||
"textWrapping" => return super::text::specs().iter().find(|c| c.id == "text.lineBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
|
||||
"textWrapping" => {
|
||||
return super::text::specs().iter().find(|c| c.id == "text.lineBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s));
|
||||
}
|
||||
"nextPage" => SectionStart::NextPage,
|
||||
"continuous" => SectionStart::Continuous,
|
||||
"evenPage" => SectionStart::EvenPage,
|
||||
|
|
|
|||
|
|
@ -274,10 +274,8 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
p.style = Some(id.clone());
|
||||
// Applying a heading or Normal removes direct list numbering (except list styles).
|
||||
if id.starts_with("Heading") || id == "Title" || id == "Normal" {
|
||||
if p.numbering.is_some_and(|n| n.num != 0) && id == "Normal" {
|
||||
p.numbering = None;
|
||||
}
|
||||
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) && id == "Normal" {
|
||||
p.numbering = None;
|
||||
}
|
||||
})?;
|
||||
// A paragraph style resets direct character formatting that matches nothing (Word keeps
|
||||
|
|
@ -306,11 +304,15 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
|||
let off = p::bool(v, "off").unwrap_or(false);
|
||||
let (a, b) = s.sel.ordered();
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> = s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let current: Vec<Option<NumRef>> =
|
||||
s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let same_kind = |n: &NumRef| {
|
||||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
want.first().is_some_and(|w| (w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet) && (p::str(v, "kind").is_none() || w.text == l.text))
|
||||
want.first().is_some_and(|w| {
|
||||
(w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet)
|
||||
&& (p::str(v, "kind").is_none() || w.text == l.text)
|
||||
})
|
||||
})
|
||||
};
|
||||
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
|
||||
|
|
@ -325,7 +327,11 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
|||
});
|
||||
}
|
||||
// Continue the list of the previous paragraph if it has the same kind; else a new list.
|
||||
let prev_num = s.doc.prev_para(a.story, &a.path).and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering)).filter(|n| n.num != 0 && same_kind(n));
|
||||
let prev_num = s
|
||||
.doc
|
||||
.prev_para(a.story, &a.path)
|
||||
.and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering))
|
||||
.filter(|n| n.num != 0 && same_kind(n));
|
||||
let num = match prev_num {
|
||||
Some(n) => n.num,
|
||||
None => match kind {
|
||||
|
|
@ -443,7 +449,16 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let based = p::str(v, "basedOn").and_then(|b| s.doc.styles.find(b).map(|x| x.id.clone())).or(Some("Normal".into()));
|
||||
let mut st = Style { id: id.clone(), name, kind: StyleKind::Paragraph, based_on: based, next: Some(id.clone()), quick: true, priority: Some(50), ..Default::default() };
|
||||
let mut st = Style {
|
||||
id: id.clone(),
|
||||
name,
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: based,
|
||||
next: Some(id.clone()),
|
||||
quick: true,
|
||||
priority: Some(50),
|
||||
..Default::default()
|
||||
};
|
||||
if p::bool(v, "fromSelection").unwrap_or(true)
|
||||
&& let Some(para) = s.doc.para_at(&s.sel.focus)
|
||||
{
|
||||
|
|
@ -463,8 +478,10 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let chr: Option<wordcraft_doc::CharProps> = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let para: Option<ParaProps> = v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let chr: Option<wordcraft_doc::CharProps> =
|
||||
v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let para: Option<ParaProps> =
|
||||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let next = p::str(v, "next").map(str::to_string);
|
||||
let name = p::str(v, "name").map(str::to_string);
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params(r#"{"levels"?: 1-9, "title"?: string}"#),
|
||||
CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc)
|
||||
.params(r#"{"levels"?: 1-9, "title"?: string}"#),
|
||||
CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| {
|
||||
update_toc(s)?;
|
||||
sel_result(s)
|
||||
|
|
@ -29,8 +30,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
super::para::fmt(s, &|pp| pp.outline_level = if level == 0 { Some(9) } else { Some(level - 1) })
|
||||
})
|
||||
.params(r#"{"level": 0 (do not show) | 1-9}"#),
|
||||
CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote)).key("Mod+Alt+F").params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote)).key("Mod+Alt+D").params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote))
|
||||
.key("Mod+Alt+F")
|
||||
.params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote))
|
||||
.key("Mod+Alt+D")
|
||||
.params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| {
|
||||
let caret = s.sel.focus.clone();
|
||||
let refs = note_refs(s);
|
||||
|
|
@ -50,7 +55,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(Value::Array(out))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption).params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#),
|
||||
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption)
|
||||
.params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#),
|
||||
CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| {
|
||||
update_fields(s)?;
|
||||
sel_result(s)
|
||||
|
|
@ -87,7 +93,9 @@ fn headings(s: &Session, levels: u8) -> Vec<(Path, u8, String)> {
|
|||
/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after it.
|
||||
fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
let start = s.doc.body.iter().position(|b| {
|
||||
b.as_para().is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr.trim_start().to_ascii_uppercase().starts_with("TOC"))))
|
||||
b.as_para().is_some_and(|p| {
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr.trim_start().to_ascii_uppercase().starts_with("TOC")))
|
||||
})
|
||||
})?;
|
||||
let mut end = start;
|
||||
while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) {
|
||||
|
|
@ -107,7 +115,11 @@ fn toc_levels(s: &Session, start: usize) -> u8 {
|
|||
.and_then(|b| b.as_para())
|
||||
.and_then(|p| {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => instr.split("\\o").nth(1).and_then(|r| r.trim().trim_matches('"').split('-').nth(1)).and_then(|n| n.trim_matches('"').parse::<u8>().ok()),
|
||||
InlineObject::Field { instr, .. } => instr
|
||||
.split("\\o")
|
||||
.nth(1)
|
||||
.and_then(|r| r.trim().trim_matches('"').split('-').nth(1))
|
||||
.and_then(|n| n.trim_matches('"').parse::<u8>().ok()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
|
|
@ -128,7 +140,11 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
// Title paragraph carrying the TOC field marker.
|
||||
let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading");
|
||||
head.insert_object(head.len(), InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false }, &CharProps::default())?;
|
||||
head.insert_object(
|
||||
head.len(),
|
||||
InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false },
|
||||
&CharProps::default(),
|
||||
)?;
|
||||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
|
||||
update_toc(s)?;
|
||||
|
|
@ -156,7 +172,8 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
|
|||
let shift = if pass == 0 { 0 } else { entries.len() };
|
||||
for (k, (path, level, text)) in entries.iter().enumerate() {
|
||||
let target = Path::top(path.last() + shift);
|
||||
let page = l.caret(&Pos { story: StoryRef::Body, path: target, off: 0 }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
|
||||
let page =
|
||||
l.caret(&Pos { story: StoryRef::Body, path: target, off: 0 }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
|
||||
let mut para = Paragraph::with_text(&format!("{text}\t{page}"), CharProps::default()).styled(&format!("TOC{}", level + 1));
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
|
||||
|
|
@ -174,12 +191,20 @@ fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
|
|||
};
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
let id = s.doc.add_part(pk, Vec::new());
|
||||
para.insert_object(0, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
|
||||
para.insert_object(
|
||||
0,
|
||||
InlineObject::NoteRef { kind, id, custom: String::new() },
|
||||
&CharProps { style: Some(refstyle.into()), ..Default::default() },
|
||||
)?;
|
||||
let len = para.len();
|
||||
para.insert_text(len, &format!(" {text}"), &CharProps::default())?;
|
||||
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
|
||||
let at = delete_selection(s)?;
|
||||
s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
|
||||
s.doc.insert_object(
|
||||
&at,
|
||||
InlineObject::NoteRef { kind, id, custom: String::new() },
|
||||
&CharProps { style: Some(refstyle.into()), ..Default::default() },
|
||||
)?;
|
||||
// Like Word, the caret moves into the new note.
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"id": id}))
|
||||
|
|
@ -220,7 +245,11 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
|||
para.mark = CharProps::default();
|
||||
let base = format!("{label} ");
|
||||
para.insert_text(0, &base, &CharProps::default())?;
|
||||
para.insert_object(base.len(), InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false }, &CharProps::default())?;
|
||||
para.insert_object(
|
||||
base.len(),
|
||||
InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false },
|
||||
&CharProps::default(),
|
||||
)?;
|
||||
let end = para.len();
|
||||
if !text.is_empty() {
|
||||
para.insert_text(end, &format!(": {text}"), &CharProps::default())?;
|
||||
|
|
@ -244,7 +273,11 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
|||
let name = wordcraft_layout::fields::field_name(instr);
|
||||
match name.as_str() {
|
||||
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
|
||||
let pic = instr.split("\\@").nth(1).map(|x| x.trim().trim_matches('"').to_string()).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
|
||||
let pic = instr
|
||||
.split("\\@")
|
||||
.nth(1)
|
||||
.map(|x| x.trim().trim_matches('"').to_string())
|
||||
.unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
|
||||
updates.push((k, super::insert::format_date(&pic)));
|
||||
}
|
||||
"SEQ" => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("review.addToDictionary", "Add to Dictionary", "Review › Proofing", |s, v| {
|
||||
let w = match p::str(v, "word") {
|
||||
Some(w) => w.to_string(),
|
||||
None => issue_at(s, &s.sel.focus.clone()).map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default()).unwrap_or_default(),
|
||||
None => issue_at(s, &s.sel.focus.clone())
|
||||
.map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
if w.is_empty() {
|
||||
return Err(CmdError::Params("no word".into()));
|
||||
|
|
@ -59,7 +61,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("review.ignoreAll", "Ignore All", "Review › Proofing", |s, v| {
|
||||
let w = match p::str(v, "word") {
|
||||
Some(w) => w.to_string(),
|
||||
None => issue_at(s, &s.sel.focus.clone()).map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default()).unwrap_or_default(),
|
||||
None => issue_at(s, &s.sel.focus.clone())
|
||||
.map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
wordcraft_proof::add_word(&w);
|
||||
s.relayout();
|
||||
|
|
@ -241,8 +245,11 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
|||
/// Accept or reject a revision range in one paragraph.
|
||||
fn resolve_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, from: usize, to: usize, accept: bool) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
let ranges: Vec<(usize, usize, bool, bool)> =
|
||||
para.run_ranges().filter(|(r, _)| r.end > from && r.start < to).map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some())).collect();
|
||||
let ranges: Vec<(usize, usize, bool, bool)> = para
|
||||
.run_ranges()
|
||||
.filter(|(r, _)| r.end > from && r.start < to)
|
||||
.map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some()))
|
||||
.collect();
|
||||
for (a, b, ins, del) in ranges.into_iter().rev() {
|
||||
if (del && accept) || (ins && !accept) {
|
||||
para.delete(a, b)?;
|
||||
|
|
@ -276,7 +283,10 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
|||
let (a, b) = s.sel.ordered();
|
||||
let (a, b) = if a == b {
|
||||
// The change at the caret: the run around it.
|
||||
let found = s.doc.para_at(&a).and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r));
|
||||
let found = s
|
||||
.doc
|
||||
.para_at(&a)
|
||||
.and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r));
|
||||
match found {
|
||||
Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }),
|
||||
None => return nav_change(s, 1),
|
||||
|
|
@ -348,10 +358,16 @@ fn word_count(s: &mut Session, _: &Value) -> CmdResult {
|
|||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, story: StoryRef::Body, .. } = it { l1 - l0 } else { 0 }).sum()
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, story: StoryRef::Body, .. } = it { l1 - l0 } else { 0 })
|
||||
.sum()
|
||||
};
|
||||
let pages = s.layout().pages.len();
|
||||
Ok(json!({"pages": pages, "words": words, "characters": chars_no_spaces, "charactersWithSpaces": chars, "paragraphs": paragraphs, "lines": lines}))
|
||||
Ok(
|
||||
json!({"pages": pages, "words": words, "characters": chars_no_spaces, "charactersWithSpaces": chars, "paragraphs": paragraphs, "lines": lines}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Issues (spelling + grammar) in one paragraph as positions.
|
||||
|
|
|
|||
|
|
@ -60,13 +60,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
l.banded_columns = set("bandedColumns", l.banded_columns);
|
||||
})
|
||||
})
|
||||
.params(r#"{"headerRow"?: bool, "totalRow"?: bool, "bandedRows"?: bool, "firstColumn"?: bool, "lastColumn"?: bool, "bandedColumns"?: bool}"#)),
|
||||
.params(
|
||||
r#"{"headerRow"?: bool, "totalRow"?: bool, "bandedRows"?: bool, "firstColumn"?: bool, "lastColumn"?: bool, "bandedColumns"?: bool}"#,
|
||||
)),
|
||||
t(CommandSpec::new("table.shading", "Shading", "Table Design › Table Styles", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
with_cells(s, |cl| cl.props.shading = c)
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null}"#)),
|
||||
t(CommandSpec::new("table.borders", "Borders", "Table Design › Borders", borders).params(r#"{"kind": "all|outside|inside|none|top|bottom|left|right", "width"?: pt, "color"?: "RRGGBB"}"#)),
|
||||
t(CommandSpec::new("table.borders", "Borders", "Table Design › Borders", borders)
|
||||
.params(r#"{"kind": "all|outside|inside|none|top|bottom|left|right", "width"?: pt, "color"?: "RRGGBB"}"#)),
|
||||
t(CommandSpec::new("table.cellAlign", "Alignment", "Table Layout › Alignment", |s, v| {
|
||||
let (h, va) = match p::str(v, "value").unwrap_or("topLeft") {
|
||||
"topLeft" => (Align::Left, VAlign::Top),
|
||||
|
|
@ -156,7 +159,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}
|
||||
})
|
||||
})),
|
||||
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table).params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
|
||||
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
|
||||
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
|
||||
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
|
||||
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
|
||||
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
|
||||
|
|
@ -441,7 +445,10 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase();
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let nums: Vec<f64> = if f.contains("LEFT") {
|
||||
t.rows.get(r).map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()).unwrap_or_default()
|
||||
t.rows
|
||||
.get(r)
|
||||
.map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()
|
||||
};
|
||||
|
|
@ -479,7 +486,7 @@ fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option<usize>)
|
|||
a.extend([r0 as u32, c0 as u32, 0]);
|
||||
let mut b = tp.0.clone();
|
||||
b.extend([r1 as u32, last_c as u32]);
|
||||
let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&b)).last().unwrap_or_else(|| {
|
||||
let end_path = s.doc.para_paths(story).into_iter().rfind(|q| q.0.starts_with(&b)).unwrap_or_else(|| {
|
||||
let mut x = b.clone();
|
||||
x.push(0);
|
||||
Path(x)
|
||||
|
|
@ -495,7 +502,8 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
|||
if a.path.parent() != b.path.parent() || a.story != b.story {
|
||||
return Err(CmdError::Failed("select paragraphs to convert".into()));
|
||||
}
|
||||
let lines: Vec<String> = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect();
|
||||
let lines: Vec<String> =
|
||||
(a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect();
|
||||
let cols = lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1).clamp(1, 63);
|
||||
let width = super::page::sect(s).text_width();
|
||||
let mut t = Table::new(lines.len().max(1), cols, width);
|
||||
|
|
@ -521,14 +529,30 @@ fn quick_table(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let data: Vec<Vec<&str>> = match kind {
|
||||
"calendar" => {
|
||||
let mut rows = vec![vec!["M", "T", "W", "T", "F", "S", "S"]];
|
||||
let days = ["", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", ""];
|
||||
let days = [
|
||||
"", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
|
||||
"23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", "",
|
||||
];
|
||||
for ch in days.chunks(7) {
|
||||
rows.push(ch.to_vec());
|
||||
}
|
||||
rows
|
||||
}
|
||||
"matrix" => vec![vec!["", "Column A", "Column B", "Column C"], vec!["Row 1", "1", "2", "3"], vec!["Row 2", "4", "5", "6"], vec!["Row 3", "7", "8", "9"]],
|
||||
_ => vec![vec!["Item", "Needed"], vec!["Books", "1"], vec!["Magazines", "3"], vec!["Notebooks", "1"], vec!["Paper pads", "1"], vec!["Pens", "3"], vec!["Pencils", "2"]],
|
||||
"matrix" => vec![
|
||||
vec!["", "Column A", "Column B", "Column C"],
|
||||
vec!["Row 1", "1", "2", "3"],
|
||||
vec!["Row 2", "4", "5", "6"],
|
||||
vec!["Row 3", "7", "8", "9"],
|
||||
],
|
||||
_ => vec![
|
||||
vec!["Item", "Needed"],
|
||||
vec!["Books", "1"],
|
||||
vec!["Magazines", "3"],
|
||||
vec!["Notebooks", "1"],
|
||||
vec!["Paper pads", "1"],
|
||||
vec!["Pens", "3"],
|
||||
vec!["Pencils", "2"],
|
||||
],
|
||||
};
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1);
|
||||
let width = super::page::sect(s).text_width();
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
|||
path.extend([nr as u32, nc as u32, 0]);
|
||||
let start = Pos { story, path: wordcraft_doc::Path(path), off: 0 };
|
||||
// Select the cell's content like Word.
|
||||
let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&start.path.0[..start.path.0.len() - 1])).last();
|
||||
let end_path = s.doc.para_paths(story).into_iter().rfind(|q| q.0.starts_with(&start.path.0[..start.path.0.len() - 1]));
|
||||
let end = end_path.map(|q| {
|
||||
let off = s.doc.para(story, &q).map(|x| x.len()).unwrap_or(0);
|
||||
Pos { story, path: q, off }
|
||||
|
|
@ -240,7 +240,7 @@ fn backspace(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
Some(Block::Table(_)) => {
|
||||
// Move into the table's last cell.
|
||||
let last = s.doc.para_paths(f.story).into_iter().filter(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1).last();
|
||||
let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1);
|
||||
if let Some(q) = last {
|
||||
let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0);
|
||||
if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) {
|
||||
|
|
|
|||
|
|
@ -55,28 +55,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.key("F4")
|
||||
.pure(),
|
||||
CommandSpec::new("tools.recordMacro", "Record Macro", "View › Macros", |s, v| {
|
||||
match s.recording.take() {
|
||||
Some((name, steps)) => {
|
||||
let n = steps.len();
|
||||
s.macros.insert(name.clone(), steps);
|
||||
Ok(json!({"recording": false, "saved": name, "steps": n}))
|
||||
}
|
||||
None => {
|
||||
let name = p::str(v, "name").unwrap_or("Macro1").to_string();
|
||||
s.recording = Some((name.clone(), Vec::new()));
|
||||
Ok(json!({"recording": true, "name": name}))
|
||||
}
|
||||
CommandSpec::new("tools.recordMacro", "Record Macro", "View › Macros", |s, v| match s.recording.take() {
|
||||
Some((name, steps)) => {
|
||||
let n = steps.len();
|
||||
s.macros.insert(name.clone(), steps);
|
||||
Ok(json!({"recording": false, "saved": name, "steps": n}))
|
||||
}
|
||||
None => {
|
||||
let name = p::str(v, "name").unwrap_or("Macro1").to_string();
|
||||
s.recording = Some((name.clone(), Vec::new()));
|
||||
Ok(json!({"recording": true, "name": name}))
|
||||
}
|
||||
})
|
||||
.params(r#"{"name"?: string} (call again to stop)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.macros", "Macros", "View › Macros", macros).params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#).pure(),
|
||||
CommandSpec::new("tools.macros", "Macros", "View › Macros", macros)
|
||||
.params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", |s, v| {
|
||||
if let Some(b) = p::bool(v, "enabled") {
|
||||
s.autocorrect_on = b;
|
||||
}
|
||||
if let (Some(f), Some(t)) = (v.get("add").and_then(|a| a.get("from")).and_then(Value::as_str), v.get("add").and_then(|a| a.get("to")).and_then(Value::as_str)) {
|
||||
if let (Some(f), Some(t)) =
|
||||
(v.get("add").and_then(|a| a.get("from")).and_then(Value::as_str), v.get("add").and_then(|a| a.get("to")).and_then(Value::as_str))
|
||||
{
|
||||
s.autocorrect_user.retain(|(a, _)| a != f);
|
||||
s.autocorrect_user.push((f.to_string(), t.to_string()));
|
||||
}
|
||||
|
|
@ -87,7 +89,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(r#"{"path"?: string, "text"?: string (revised version)}"#),
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(r#"{"path"?: string}"#),
|
||||
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
|
||||
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc).params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]}"#),
|
||||
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc)
|
||||
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]}"#),
|
||||
CommandSpec::new("review.restrict", "Restrict Editing", "Review › Protect", |s, v| {
|
||||
let mode = p::str(v, "mode").unwrap_or("readOnly").to_string();
|
||||
s.doc.settings.protection = if mode == "none" { None } else { Some(mode.clone()) };
|
||||
|
|
@ -152,7 +155,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
if s.doc.settings.watermark.is_some() {
|
||||
issues.push("Watermarks are kept in WordCraft documents but not yet written to .docx.");
|
||||
}
|
||||
if s.doc.para_paths(StoryRef::Body).iter().any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.objects.iter().any(|o| matches!(o, InlineObject::Opaque { .. })))) {
|
||||
if s.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.objects.iter().any(|o| matches!(o, InlineObject::Opaque { .. }))))
|
||||
{
|
||||
issues.push("Some embedded objects from the original file are shown as text only.");
|
||||
}
|
||||
Ok(json!({"issues": issues}))
|
||||
|
|
@ -168,7 +175,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Err(CmdError::Failed("password encryption isn't available yet".into()))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts).params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
|
||||
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts)
|
||||
.params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
|
||||
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#),
|
||||
CommandSpec::new("insert.docProperty", "Document Property", "Insert › Text › Quick Parts", |s, v| {
|
||||
let name = p::str(v, "name").unwrap_or("Title").to_string();
|
||||
|
|
@ -205,7 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
if !title.is_empty() {
|
||||
lines.push(title);
|
||||
}
|
||||
let frag = wordcraft_doc::edit::Fragment { blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l, CharProps::default()).styled("NoSpacing"))).collect() };
|
||||
let frag = wordcraft_doc::edit::Fragment {
|
||||
blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l, CharProps::default()).styled("NoSpacing"))).collect(),
|
||||
};
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
sel_result(s)
|
||||
|
|
@ -270,7 +280,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
|
||||
let m = [p::f32(v, "top").unwrap_or(0.0), p::f32(v, "left").unwrap_or(5.4), p::f32(v, "bottom").unwrap_or(0.0), p::f32(v, "right").unwrap_or(5.4)].map(|x| x.clamp(0.0, 144.0));
|
||||
let m = [
|
||||
p::f32(v, "top").unwrap_or(0.0),
|
||||
p::f32(v, "left").unwrap_or(5.4),
|
||||
p::f32(v, "bottom").unwrap_or(0.0),
|
||||
p::f32(v, "right").unwrap_or(5.4),
|
||||
]
|
||||
.map(|x| x.clamp(0.0, 144.0));
|
||||
let story = s.sel.focus.story;
|
||||
s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m);
|
||||
for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) {
|
||||
|
|
@ -279,11 +295,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"top"?, "left"?, "bottom"?, "right"? (pt)}"#),
|
||||
CommandSpec::new("table.borderPainter", "Border Painter", "Table Design › Borders", |s, v| s.run("table.borders", &json!({"kind": p::str(v, "kind").unwrap_or("all"), "width": p::f32(v, "width").unwrap_or(0.5), "color": p::str(v, "color")}))),
|
||||
CommandSpec::new("table.borderPainter", "Border Painter", "Table Design › Borders", |s, v| {
|
||||
s.run(
|
||||
"table.borders",
|
||||
&json!({"kind": p::str(v, "kind").unwrap_or("all"), "width": p::f32(v, "width").unwrap_or(0.5), "color": p::str(v, "color")}),
|
||||
)
|
||||
}),
|
||||
CommandSpec::new("para.setNumberingValue", "Set Numbering Value", "Home › Paragraph › Numbering", |s, v| {
|
||||
let value = p::u64(v, "value").unwrap_or(1).clamp(0, 100_000) as u32;
|
||||
let f = s.sel.focus.clone();
|
||||
let n = s.doc.para_at(&f).and_then(|x| x.props.numbering).filter(|n| n.num != 0).ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
|
||||
let n = s
|
||||
.doc
|
||||
.para_at(&f)
|
||||
.and_then(|x| x.props.numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
|
||||
num.start_overrides = vec![(n.level, value)];
|
||||
|
|
@ -300,7 +326,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"value": n}"#),
|
||||
CommandSpec::new("para.defineBullet", "Define New Bullet", "Home › Paragraph › Bullets", |s, v| s.run("para.bullets", &json!({"kind": p::str(v, "char").unwrap_or("★")}))).params(r#"{"char": string}"#),
|
||||
CommandSpec::new("para.defineBullet", "Define New Bullet", "Home › Paragraph › Bullets", |s, v| {
|
||||
s.run("para.bullets", &json!({"kind": p::str(v, "char").unwrap_or("★")}))
|
||||
})
|
||||
.params(r#"{"char": string}"#),
|
||||
CommandSpec::new("para.defineNumber", "Define New Number Format", "Home › Paragraph › Numbering", |s, v| {
|
||||
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
|
||||
let text = p::str(v, "text").unwrap_or("%1.").to_string();
|
||||
|
|
@ -354,8 +383,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"select.word"
|
||||
} else if a.path == b.path && (a.off > 0 || b.off < para_len) {
|
||||
if s.doc.para_at(&a).map(|x| x.sentence_at(a.off)) == Some((a.off, b.off)) { "select.paragraph" } else { "select.sentence" }
|
||||
} else if a.path == b.path || a.path.with_last(a.path.last() + 1) == b.path {
|
||||
"select.all"
|
||||
} else {
|
||||
"select.all"
|
||||
};
|
||||
|
|
@ -364,7 +391,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.key("F8")
|
||||
.pure(),
|
||||
CommandSpec::new("design.effects", "Effects", "Design › Document Formatting", |s, v| {
|
||||
s.doc.settings.theme_name = format!("{} ({})", s.doc.settings.theme_name.split(" (").next().unwrap_or("Craft"), p::str(v, "name").unwrap_or("Subtle"));
|
||||
s.doc.settings.theme_name =
|
||||
format!("{} ({})", s.doc.settings.theme_name.split(" (").next().unwrap_or("Craft"), p::str(v, "name").unwrap_or("Subtle"));
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("view.immersive", "Immersive Reader", "View › Immersive", |s, _| {
|
||||
|
|
@ -394,7 +422,9 @@ fn macros(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let steps: Vec<(String, Value)> = d
|
||||
.get("steps")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|st| Some((st.get("command")?.as_str()?.to_string(), st.get("params").cloned().unwrap_or(json!({}))))).collect())
|
||||
.map(|a| {
|
||||
a.iter().filter_map(|st| Some((st.get("command")?.as_str()?.to_string(), st.get("params").cloned().unwrap_or(json!({}))))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
s.macros.insert(name, steps);
|
||||
}
|
||||
|
|
@ -411,7 +441,9 @@ fn macros(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
return Ok(json!({"ran": n, "steps": steps.len()}));
|
||||
}
|
||||
Ok(Value::Object(s.macros.iter().map(|(k, v)| (k.clone(), json!(v.iter().map(|(c, p)| json!({"command": c, "params": p})).collect::<Vec<_>>()))).collect()))
|
||||
Ok(Value::Object(
|
||||
s.macros.iter().map(|(k, v)| (k.clone(), json!(v.iter().map(|(c, p)| json!({"command": c, "params": p})).collect::<Vec<_>>()))).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Apply AutoCorrect to the word just before the caret (called after typing a space/punctuation).
|
||||
|
|
@ -434,14 +466,19 @@ pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
|
|||
return Ok(());
|
||||
}
|
||||
let user = s.autocorrect_user.iter().find(|(a, _)| a == word).map(|(_, b)| b.clone());
|
||||
let builtin = AUTOCORRECT.iter().find(|(a, _)| *a == word || (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))).map(|(a, b)| {
|
||||
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
|
||||
let mut c = b.chars();
|
||||
c.next().map(|x| x.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
b.to_string()
|
||||
}
|
||||
});
|
||||
let builtin = AUTOCORRECT
|
||||
.iter()
|
||||
.find(|(a, _)| {
|
||||
*a == word || (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|
||||
})
|
||||
.map(|(a, b)| {
|
||||
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
|
||||
let mut c = b.chars();
|
||||
c.next().map(|x| x.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
b.to_string()
|
||||
}
|
||||
});
|
||||
let Some(rep) = user.or(builtin) else { return Ok(()) };
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let props = para.props_of_char(start).clone();
|
||||
|
|
@ -649,8 +686,13 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let remove: Vec<String> = v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
let has_hidden = s.doc.para_paths(StoryRef::Body).iter().any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.hidden == Some(true))));
|
||||
let remove: Vec<String> =
|
||||
v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
let has_hidden = s
|
||||
.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.hidden == Some(true))));
|
||||
let report = json!({
|
||||
"comments": s.doc.comments.len(),
|
||||
"revisions": s.doc.para_paths(StoryRef::Body).iter().filter(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.ins.is_some() || r.props.del.is_some()))).count(),
|
||||
|
|
@ -673,7 +715,11 @@ fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
"hidden" => {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let ranges: Vec<(usize, usize)> = s.doc.para(StoryRef::Body, &path).map(|p| p.run_ranges().filter(|(_, c)| c.hidden == Some(true)).map(|(r, _)| (r.start, r.end)).collect()).unwrap_or_default();
|
||||
let ranges: Vec<(usize, usize)> = s
|
||||
.doc
|
||||
.para(StoryRef::Body, &path)
|
||||
.map(|p| p.run_ranges().filter(|(_, c)| c.hidden == Some(true)).map(|(r, _)| (r.start, r.end)).collect())
|
||||
.unwrap_or_default();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (a, b) in ranges.into_iter().rev() {
|
||||
para.delete(a, b)?;
|
||||
|
|
@ -716,7 +762,10 @@ fn hf_nav(s: &mut Session, dir: i32) -> CmdResult {
|
|||
let StoryRef::Part(cur) = s.sel.focus.story else { return Err(CmdError::Disabled("edit a header or footer first".into())) };
|
||||
let mut ids: Vec<u32> = Vec::new();
|
||||
for (_, sect) in s.doc.sections() {
|
||||
for id in [sect.headers.default, sect.headers.first, sect.headers.even, sect.footers.default, sect.footers.first, sect.footers.even].into_iter().flatten() {
|
||||
for id in [sect.headers.default, sect.headers.first, sect.headers.even, sect.footers.default, sect.footers.first, sect.footers.even]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
|
|
@ -732,7 +781,13 @@ fn hf_nav(s: &mut Session, dir: i32) -> CmdResult {
|
|||
fn read_aloud(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let text = if s.sel.is_collapsed() {
|
||||
let f = s.sel.focus.clone();
|
||||
s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).filter_map(|p| s.doc.para(f.story, &p).map(|x| x.plain_text())).collect::<Vec<_>>().join("\n")
|
||||
s.doc
|
||||
.para_paths(f.story)
|
||||
.into_iter()
|
||||
.filter(|p| *p >= f.path)
|
||||
.filter_map(|p| s.doc.para(f.story, &p).map(|x| x.plain_text()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
|
|
@ -744,8 +799,10 @@ fn read_aloud(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let script = format!("Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak([Console]::In.ReadToEnd())");
|
||||
if let Ok(mut c) = std::process::Command::new("powershell").args(["-NoProfile", "-Command", &script]).stdin(std::process::Stdio::piped()).spawn()
|
||||
let script =
|
||||
format!("Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak([Console]::In.ReadToEnd())");
|
||||
if let Ok(mut c) =
|
||||
std::process::Command::new("powershell").args(["-NoProfile", "-Command", &script]).stdin(std::process::Stdio::piped()).spawn()
|
||||
&& let Some(mut i) = c.stdin.take()
|
||||
{
|
||||
use std::io::Write;
|
||||
|
|
@ -755,7 +812,11 @@ fn read_aloud(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let _ = std::process::Command::new("spd-say").arg(&text).spawn().or_else(|_| std::process::Command::new("espeak").arg(&text).spawn()).map_err(|e| CmdError::Failed(format!("no speech engine found: {e}")))?;
|
||||
let _ = std::process::Command::new("spd-say")
|
||||
.arg(&text)
|
||||
.spawn()
|
||||
.or_else(|_| std::process::Command::new("espeak").arg(&text).spawn())
|
||||
.map_err(|e| CmdError::Failed(format!("no speech engine found: {e}")))?;
|
||||
return Ok(json!({"speaking": text.len()}));
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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(),
|
||||
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.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, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
|
||||
|
|
@ -63,7 +65,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))).pure(),
|
||||
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ pub fn decode_text(b: &[u8]) -> String {
|
|||
}
|
||||
if b.len() >= 2 && (b[0] == 0xFF && b[1] == 0xFE || b[0] == 0xFE && b[1] == 0xFF) {
|
||||
let le = b[0] == 0xFF;
|
||||
let units: Vec<u16> = b[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
|
||||
let units: Vec<u16> =
|
||||
b[2..].as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
}
|
||||
match std::str::from_utf8(b) {
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ pub mod p {
|
|||
v.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
/// The `value` param, or the first positional-ish string.
|
||||
pub fn value<'a>(v: &'a Value) -> Option<&'a Value> {
|
||||
pub fn value(v: &Value) -> Option<&Value> {
|
||||
v.get("value")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ pub fn sample_document() -> Document {
|
|||
add(&mut intro, "Painters", bold());
|
||||
add(&mut intro, " set up by the north windows, ", CharProps::default());
|
||||
add(&mut intro, "letterpress", italic());
|
||||
add(&mut intro, " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", CharProps::default());
|
||||
add(
|
||||
&mut intro,
|
||||
" regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.",
|
||||
CharProps::default(),
|
||||
);
|
||||
b.push(Block::Para(intro));
|
||||
b.push(Block::Para(styled("Why share a studio?", "Heading1")));
|
||||
b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point.")));
|
||||
|
|
@ -85,7 +89,12 @@ pub fn sample_document() -> Document {
|
|||
}
|
||||
b.push(Block::Table(t));
|
||||
b.push(Block::Para(styled("Opening the studio", "Heading2")));
|
||||
for s in ["Switch on the main breaker by the door.", "Open both skylights unless it is raining.", "Start the kettle. This step is not optional.", "Check the booking sheet for the kiln and press."] {
|
||||
for s in [
|
||||
"Switch on the main breaker by the door.",
|
||||
"Open both skylights unless it is raining.",
|
||||
"Start the kettle. This step is not optional.",
|
||||
"Check the booking sheet for the kiln and press.",
|
||||
] {
|
||||
let mut p = para(s).styled("ListParagraph");
|
||||
p.props.numbering = Some(NumRef { num: steps, level: 0 });
|
||||
b.push(Block::Para(p));
|
||||
|
|
@ -93,10 +102,18 @@ pub fn sample_document() -> Document {
|
|||
b.push(Block::Para(styled("Membership", "Heading1")));
|
||||
let mut m = para("Membership is ");
|
||||
add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() });
|
||||
add(&mut m, ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", CharProps::default());
|
||||
add(
|
||||
&mut m,
|
||||
", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.",
|
||||
CharProps::default(),
|
||||
);
|
||||
b.push(Block::Para(m));
|
||||
let mut h = para("Questions go to the front desk, or to the notice board by the sink. ");
|
||||
add(&mut h, "Visit the community board", CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() });
|
||||
add(
|
||||
&mut h,
|
||||
"Visit the community board",
|
||||
CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() },
|
||||
);
|
||||
add(&mut h, " for events.", CharProps::default());
|
||||
b.push(Block::Para(h));
|
||||
d.body = b.into_iter().map(std::sync::Arc::new).collect();
|
||||
|
|
@ -107,7 +124,10 @@ pub fn sample_document() -> Document {
|
|||
let fid = d.add_part(PartKind::Footer, vec![para_block(f)]);
|
||||
d.last_section.footers.default = Some(fid);
|
||||
let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header");
|
||||
hd.props.borders = Some(Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), ..Default::default() });
|
||||
hd.props.borders = Some(Borders {
|
||||
bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }),
|
||||
..Default::default()
|
||||
});
|
||||
let hid = d.add_part(PartKind::Header, vec![para_block(hd)]);
|
||||
d.last_section.headers.default = Some(hid);
|
||||
d
|
||||
|
|
@ -127,7 +147,9 @@ pub fn letter() -> Document {
|
|||
para("Street Address"),
|
||||
para(""),
|
||||
para("Dear Recipient,"),
|
||||
para("Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next."),
|
||||
para(
|
||||
"Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next.",
|
||||
),
|
||||
para("Thank you for your time."),
|
||||
para("Sincerely,"),
|
||||
para(""),
|
||||
|
|
@ -140,7 +162,8 @@ pub fn letter() -> Document {
|
|||
/// A one-page résumé.
|
||||
pub fn resume() -> Document {
|
||||
let mut d = Document::new();
|
||||
let mut b: Vec<Block> = vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))];
|
||||
let mut b: Vec<Block> =
|
||||
vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))];
|
||||
for (head, items) in [
|
||||
("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]),
|
||||
("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]),
|
||||
|
|
|
|||
|
|
@ -329,7 +329,8 @@ impl Session {
|
|||
}
|
||||
}
|
||||
// Macro recording and Repeat.
|
||||
let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat") && (spec.mutates || id.starts_with("caret.") || id.starts_with("select."));
|
||||
let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat")
|
||||
&& (spec.mutates || id.starts_with("caret.") || id.starts_with("select."));
|
||||
if record && let Some((_, steps)) = self.recording.as_mut() {
|
||||
steps.push((id.to_string(), params.clone()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,7 +241,12 @@ fn failed_command_leaves_document_unchanged() {
|
|||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let reg = cmd::registry();
|
||||
let junk = [json!(null), json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9})];
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
|
||||
json!({"value": -1e308, "rows": 1e9}),
|
||||
];
|
||||
for spec in reg.all() {
|
||||
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -37,8 +37,20 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
|||
"times new roman" | "times" => TIMES,
|
||||
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
|
||||
"symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"cambria" | "cambria math" | "georgia" | "garamond" | "book antiqua" | "palatino linotype" | "constantia" | "baskerville old face"
|
||||
| "century" | "century schoolbook" | "bookman old style" | "sitka text" | "sitka" | "aptos serif" => SERIF,
|
||||
"cambria"
|
||||
| "cambria math"
|
||||
| "georgia"
|
||||
| "garamond"
|
||||
| "book antiqua"
|
||||
| "palatino linotype"
|
||||
| "constantia"
|
||||
| "baskerville old face"
|
||||
| "century"
|
||||
| "century schoolbook"
|
||||
| "bookman old style"
|
||||
| "sitka text"
|
||||
| "sitka"
|
||||
| "aptos serif" => SERIF,
|
||||
_ if f.contains("mono") || f.contains("code") => MONO,
|
||||
_ if f.contains("serif") && !f.contains("sans") => SERIF,
|
||||
_ => SANS,
|
||||
|
|
@ -70,12 +82,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
|||
(s.to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved {
|
||||
synth_bold: bold && face.weight < 600.0,
|
||||
synth_italic: italic && !face.italic,
|
||||
substituted,
|
||||
face: FaceRef::of(&face),
|
||||
};
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, r);
|
||||
r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ impl Default for DisplayOptions {
|
|||
|
||||
/// Colour for revisions by author index.
|
||||
pub fn revision_color(i: u32) -> Rgb {
|
||||
const C: [Rgb; 6] = [Rgb(0xB0, 0x1E, 0x8F), Rgb(0x1E, 0x6E, 0xB0), Rgb(0x2E, 0x8B, 0x3E), Rgb(0xC0, 0x5A, 0x10), Rgb(0x70, 0x3C, 0xB0), Rgb(0x0E, 0x7C, 0x86)];
|
||||
const C: [Rgb; 6] =
|
||||
[Rgb(0xB0, 0x1E, 0x8F), Rgb(0x1E, 0x6E, 0xB0), Rgb(0x2E, 0x8B, 0x3E), Rgb(0xC0, 0x5A, 0x10), Rgb(0x70, 0x3C, 0xB0), Rgb(0x0E, 0x7C, 0x86)];
|
||||
C.get(i as usize % C.len()).copied().unwrap_or(C[0])
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +153,19 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
|
|||
}
|
||||
}
|
||||
|
||||
fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usize, l1: usize, x: f32, y: f32, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
fn lines(
|
||||
doc: &Document,
|
||||
story: StoryRef,
|
||||
path: &Path,
|
||||
pl: &ParaLayout,
|
||||
l0: usize,
|
||||
l1: usize,
|
||||
x: f32,
|
||||
y: f32,
|
||||
opts: &DisplayOptions,
|
||||
alpha: f32,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let Some(first) = pl.lines.get(l0) else { return };
|
||||
let para = doc.para(story, path);
|
||||
for li in l0..l1 {
|
||||
|
|
@ -240,7 +253,10 @@ fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usiz
|
|||
// Decorations across the run (underline skips trailing spaces of the line).
|
||||
let x0 = x + line.xs.get(run_start - line.c0).copied().unwrap_or(0.0);
|
||||
let mut end_k = run_end;
|
||||
while end_k > run_start && pl.clusters.get(end_k - 1).is_some_and(|c| matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak)) && end_k == line.c1 {
|
||||
while end_k > run_start
|
||||
&& pl.clusters.get(end_k - 1).is_some_and(|c| matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak))
|
||||
&& end_k == line.c1
|
||||
{
|
||||
end_k -= 1;
|
||||
}
|
||||
let x1 = x + line.xs.get(end_k - line.c0).copied().unwrap_or(0.0);
|
||||
|
|
@ -281,7 +297,16 @@ fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usiz
|
|||
}
|
||||
let y = base + 2.5;
|
||||
let color = if *grammar { Rgb(0x2B, 0x57, 0xC0) } else { Rgb(0xE0, 0x24, 0x24) };
|
||||
out.push(Draw::Line { x0: x + x0, y0: y, x1: x + x1, y1: y, width: 0.8, color, stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, alpha });
|
||||
out.push(Draw::Line {
|
||||
x0: x + x0,
|
||||
y0: y,
|
||||
x1: x + x1,
|
||||
y1: y,
|
||||
width: 0.8,
|
||||
color,
|
||||
stroke: if *grammar { Stroke::Double } else { Stroke::Wave },
|
||||
alpha,
|
||||
});
|
||||
}
|
||||
// Tab leaders.
|
||||
for (k, leader) in &line.leaders {
|
||||
|
|
@ -341,7 +366,16 @@ fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usiz
|
|||
ClKind::LineBreak => out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: '↵' }),
|
||||
ClKind::PageBreak | ClKind::ColumnBreak => {
|
||||
let label = if c.kind == ClKind::PageBreak { '⤓' } else { '⇥' };
|
||||
out.push(Draw::Line { x0: cx + 2.0, y0: base - size * 0.3, x1: x + line.right, y1: base - size * 0.3, width: 0.5, color: Rgb(0x60, 0x60, 0x60), stroke: Stroke::Dotted, alpha });
|
||||
out.push(Draw::Line {
|
||||
x0: cx + 2.0,
|
||||
y0: base - size * 0.3,
|
||||
x1: x + line.right,
|
||||
y1: base - size * 0.3,
|
||||
width: 0.5,
|
||||
color: Rgb(0x60, 0x60, 0x60),
|
||||
stroke: Stroke::Dotted,
|
||||
alpha,
|
||||
});
|
||||
out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label });
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -161,13 +161,25 @@ impl DocLayout {
|
|||
.iter()
|
||||
.filter(|l| {
|
||||
let mid = (l.top + l.bottom) / 2.0;
|
||||
if dir < 0 { mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5 } else { mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5 }
|
||||
if dir < 0 {
|
||||
mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5
|
||||
} else {
|
||||
mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let target = if dir < 0 {
|
||||
cands.iter().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).max_by(|a, b| a.top.total_cmp(&b.top)).or_else(|| cands.iter().max_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
cands
|
||||
.iter()
|
||||
.filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0)
|
||||
.max_by(|a, b| a.top.total_cmp(&b.top))
|
||||
.or_else(|| cands.iter().max_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
} else {
|
||||
cands.iter().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).min_by(|a, b| a.top.total_cmp(&b.top)).or_else(|| cands.iter().min_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
cands
|
||||
.iter()
|
||||
.filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0)
|
||||
.min_by(|a, b| a.top.total_cmp(&b.top))
|
||||
.or_else(|| cands.iter().min_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
};
|
||||
if let Some(l) = target {
|
||||
let off = l.para.off_at_x(l.li, gx - l.x);
|
||||
|
|
@ -255,7 +267,9 @@ impl DocLayout {
|
|||
pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> {
|
||||
let p = self.pages.get(page)?;
|
||||
p.items.iter().rev().find_map(|it| match it {
|
||||
Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => Some((*story, table.clone(), *row, *cell)),
|
||||
Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => {
|
||||
Some((*story, table.clone(), *row, *cell))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,13 +54,49 @@ pub struct LayoutOptions {
|
|||
#[derive(Clone, Debug)]
|
||||
pub enum Placed {
|
||||
/// Lines `l0..l1` of a paragraph; `y` is the top of line `l0`; `x` the column's left edge.
|
||||
Lines { story: StoryRef, path: Path, para: Arc<ParaLayout>, l0: usize, l1: usize, x: f32, y: f32 },
|
||||
Fill { rect: Rect, color: Rgb },
|
||||
Rule { x0: f32, y0: f32, x1: f32, y1: f32, border: Border },
|
||||
Image { rect: Rect, media: String, crop: [f32; 4], story: StoryRef, path: Path, off: usize },
|
||||
Shape { rect: Rect, kind: wordcraft_doc::para::ShapeKind, fill: Option<Rgb>, stroke: Option<Rgb>, stroke_width: f32 },
|
||||
Lines {
|
||||
story: StoryRef,
|
||||
path: Path,
|
||||
para: Arc<ParaLayout>,
|
||||
l0: usize,
|
||||
l1: usize,
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
Fill {
|
||||
rect: Rect,
|
||||
color: Rgb,
|
||||
},
|
||||
Rule {
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
border: Border,
|
||||
},
|
||||
Image {
|
||||
rect: Rect,
|
||||
media: String,
|
||||
crop: [f32; 4],
|
||||
story: StoryRef,
|
||||
path: Path,
|
||||
off: usize,
|
||||
},
|
||||
Shape {
|
||||
rect: Rect,
|
||||
kind: wordcraft_doc::para::ShapeKind,
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
},
|
||||
/// A table cell's area (for hit testing and cell selection).
|
||||
Cell { rect: Rect, table: Path, row: usize, cell: usize, story: StoryRef },
|
||||
Cell {
|
||||
rect: Rect,
|
||||
table: Path,
|
||||
row: usize,
|
||||
cell: usize,
|
||||
story: StoryRef,
|
||||
},
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
|
|
@ -201,7 +237,15 @@ impl Ctx<'_> {
|
|||
return pl.clone();
|
||||
}
|
||||
self.cache.misses += 1;
|
||||
let env = para::ParaEnv { doc: self.doc, width, label, fields: &self.fields, show_hidden: self.opts.show_hidden, table_chr, proofing: self.opts.proofing };
|
||||
let env = para::ParaEnv {
|
||||
doc: self.doc,
|
||||
width,
|
||||
label,
|
||||
fields: &self.fields,
|
||||
show_hidden: self.opts.show_hidden,
|
||||
table_chr,
|
||||
proofing: self.opts.proofing,
|
||||
};
|
||||
let pl = Arc::new(para::layout_para(p, &env));
|
||||
self.cache.paras.insert(key, pl.clone());
|
||||
pl
|
||||
|
|
@ -236,7 +280,15 @@ fn note_numbers(doc: &Document) -> HashMap<u32, u32> {
|
|||
|
||||
/// Lay out a block list into a free-standing box of `width` (no page breaks): table cells,
|
||||
/// headers, footers, text boxes. Returns items relative to (0, 0) and the height.
|
||||
fn layout_box(ctx: &mut Ctx, story: StoryRef, blocks: &Blocks, prefix: &[u32], width: f32, table_chr: Option<&CharProps>, depth: usize) -> (Vec<Placed>, f32) {
|
||||
fn layout_box(
|
||||
ctx: &mut Ctx,
|
||||
story: StoryRef,
|
||||
blocks: &Blocks,
|
||||
prefix: &[u32],
|
||||
width: f32,
|
||||
table_chr: Option<&CharProps>,
|
||||
depth: usize,
|
||||
) -> (Vec<Placed>, f32) {
|
||||
let mut items = Vec::new();
|
||||
let mut y = 0.0f32;
|
||||
let mut prev_after = 0.0f32;
|
||||
|
|
@ -356,7 +408,13 @@ impl PageBuilder<'_> {
|
|||
let mut y = self.orig_bottom - total + NOTE_SEP;
|
||||
let notes = std::mem::take(&mut self.notes);
|
||||
if let Some(pg) = self.pages.last_mut() {
|
||||
pg.items.push(Placed::Rule { x0: x, y0: y - NOTE_SEP / 2.0, x1: x + 144.0, y1: y - NOTE_SEP / 2.0, border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 } });
|
||||
pg.items.push(Placed::Rule {
|
||||
x0: x,
|
||||
y0: y - NOTE_SEP / 2.0,
|
||||
x1: x + 144.0,
|
||||
y1: y - NOTE_SEP / 2.0,
|
||||
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
|
||||
});
|
||||
for (_, items, h) in notes {
|
||||
for mut it in items {
|
||||
it.translate(x, y);
|
||||
|
|
@ -455,7 +513,21 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
} else {
|
||||
first_sect
|
||||
};
|
||||
let mut pb = PageBuilder { pages: Vec::new(), sect: sect_ref, sect_idx: 0, col: 0, cols: Vec::new(), y: 0.0, top: 0.0, bottom: 0.0, number: 0, web, notes: Vec::new(), orig_bottom: 0.0, prev: None };
|
||||
let mut pb = PageBuilder {
|
||||
pages: Vec::new(),
|
||||
sect: sect_ref,
|
||||
sect_idx: 0,
|
||||
col: 0,
|
||||
cols: Vec::new(),
|
||||
y: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
number: 0,
|
||||
web,
|
||||
notes: Vec::new(),
|
||||
orig_bottom: 0.0,
|
||||
prev: None,
|
||||
};
|
||||
let mut block = 0usize;
|
||||
for (si, (end, sect)) in sections.iter().enumerate() {
|
||||
let sect: &SectionProps = if web { sect_ref } else { sect };
|
||||
|
|
@ -473,7 +545,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
pb.new_page(block, body_top);
|
||||
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
||||
let want_even = start == SectionStart::EvenPage;
|
||||
if (pb.number % 2 == 0) != want_even {
|
||||
if pb.number.is_multiple_of(2) != want_even {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
}
|
||||
|
|
@ -498,7 +570,13 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
}
|
||||
// Endnotes after the last paragraph.
|
||||
let endnotes: Vec<u32> = {
|
||||
let mut ids: Vec<(u32, u32)> = ctx.fields.notes.iter().filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote)).map(|(a, b)| (*b, *a)).collect();
|
||||
let mut ids: Vec<(u32, u32)> = ctx
|
||||
.fields
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote))
|
||||
.map(|(a, b)| (*b, *a))
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids.into_iter().map(|(_, id)| id).collect()
|
||||
};
|
||||
|
|
@ -506,7 +584,13 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
pb.y += 12.0;
|
||||
let (x, ry) = (pb.col_x(), pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + 144.0, y1: ry, border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 } });
|
||||
pg.items.push(Placed::Rule {
|
||||
x0: x,
|
||||
y0: ry,
|
||||
x1: x + 144.0,
|
||||
y1: ry,
|
||||
border: Border { style: wordcraft_doc::props::BorderStyle::Single, width: 0.5, color: None, space: 0.0 },
|
||||
});
|
||||
}
|
||||
pb.y += 8.0;
|
||||
for id in endnotes {
|
||||
|
|
@ -529,7 +613,11 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
|||
pb.flush_notes();
|
||||
let mut pages = pb.pages;
|
||||
if web && let Some(p) = pages.first_mut() {
|
||||
let bottom = p.items.iter().filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }).fold(0.0f32, f32::max);
|
||||
let bottom = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None })
|
||||
.fold(0.0f32, f32::max);
|
||||
p.h = bottom + 36.0;
|
||||
}
|
||||
if !web {
|
||||
|
|
@ -610,17 +698,23 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
let mut line_notes = Vec::new();
|
||||
if !pb.web {
|
||||
for (ci, id) in &pl.notes {
|
||||
if *ci >= l.c0 && *ci < l.c1 && !pb.notes.iter().chain(new_notes.iter()).any(|x| x.0 == *id) {
|
||||
if let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) {
|
||||
let blocks = part.blocks.clone();
|
||||
let (items, h) = layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0);
|
||||
line_notes.push((*id, items, h));
|
||||
}
|
||||
if *ci >= l.c0
|
||||
&& *ci < l.c1
|
||||
&& !pb.notes.iter().chain(new_notes.iter()).any(|x| x.0 == *id)
|
||||
&& let Some(part) = ctx.doc.parts.get(id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote)
|
||||
{
|
||||
let blocks = part.blocks.clone();
|
||||
let (items, h) = layout_box(ctx, StoryRef::Part(*id), &blocks, &[], pb.sect.text_width(), None, 0);
|
||||
line_notes.push((*id, items, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra: f32 = line_notes.iter().map(|n| n.2).sum::<f32>() + if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { NOTE_SEP } else { 0.0 };
|
||||
let limit = pb.bottom - new_notes.iter().map(|n| n.2).sum::<f32>() - if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 } - extra;
|
||||
let extra: f32 = line_notes.iter().map(|n| n.2).sum::<f32>()
|
||||
+ if pb.notes.is_empty() && new_notes.is_empty() && !line_notes.is_empty() { NOTE_SEP } else { 0.0 };
|
||||
let limit = pb.bottom
|
||||
- new_notes.iter().map(|n| n.2).sum::<f32>()
|
||||
- if pb.notes.is_empty() && !new_notes.is_empty() { NOTE_SEP } else { 0.0 }
|
||||
- extra;
|
||||
let bottom = pb.y + (l.top + l.height - first.top);
|
||||
if bottom > limit + 0.01 && l1 > l0 {
|
||||
break;
|
||||
|
|
@ -669,7 +763,9 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let (w, h, float) = match obj {
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => (*w, *h, *float),
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => {
|
||||
(*w, *h, *float)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let fx = match float.h_rel {
|
||||
|
|
@ -684,8 +780,12 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
};
|
||||
let rect = Rect::new(fx, fy, w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let it = match obj {
|
||||
InlineObject::Image { media, crop, .. } => Placed::Image { rect, media: media.clone(), crop: *crop, story: StoryRef::Body, path: Path(vec![block as u32]), off: c.start },
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width },
|
||||
InlineObject::Image { media, crop, .. } => {
|
||||
Placed::Image { rect, media: media.clone(), crop: *crop, story: StoryRef::Body, path: Path(vec![block as u32]), off: c.start }
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
|
||||
Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width }
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if float.wrap == Wrap::BehindText {
|
||||
|
|
@ -722,7 +822,15 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
|||
Some(Block::Para(p)) => {
|
||||
// Don't advance list counters for a lookahead: lay out without the label.
|
||||
let rp = ctx.doc.styles.resolve_para(&p.props);
|
||||
let env = para::ParaEnv { doc: ctx.doc, width, label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, table_chr: None, proofing: false };
|
||||
let env = para::ParaEnv {
|
||||
doc: ctx.doc,
|
||||
width,
|
||||
label: None,
|
||||
fields: &ctx.fields,
|
||||
show_hidden: ctx.opts.show_hidden,
|
||||
table_chr: None,
|
||||
proofing: false,
|
||||
};
|
||||
let _ = rp;
|
||||
let pl = para::layout_para(p, &env);
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
|
|
|
|||
|
|
@ -164,7 +164,11 @@ impl<'a> Builder<'a> {
|
|||
fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u16 {
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let face = face_override.unwrap_or(r.face);
|
||||
let key = (format!("{}|{:?}|{}|{}", style_key(rc), rc.color, rc.underline as u8, small), face.id(), rc.strike || rc.double_strike || rc.link.is_some());
|
||||
let key = (
|
||||
format!("{}|{:?}|{}|{}", style_key(rc), rc.color, rc.underline as u8, small),
|
||||
face.id(),
|
||||
rc.strike || rc.double_strike || rc.link.is_some(),
|
||||
);
|
||||
let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden), key.1, key.2);
|
||||
if let Some(i) = self.style_index.get(&key) {
|
||||
return *i;
|
||||
|
|
@ -277,7 +281,18 @@ impl<'a> Builder<'a> {
|
|||
let Some(first) = added.first() else {
|
||||
let si = self.style(rc, None, false);
|
||||
let g = self.glyphs.len() as u32;
|
||||
self.clusters.push(Cluster { start, end, adv: 0.0, kind: ClKind::Marker, style: si, g0: g, g1: g, break_after: false, obj_h: 0.0, dot: false });
|
||||
self.clusters.push(Cluster {
|
||||
start,
|
||||
end,
|
||||
adv: 0.0,
|
||||
kind: ClKind::Marker,
|
||||
style: si,
|
||||
g0: g,
|
||||
g1: g,
|
||||
break_after: false,
|
||||
obj_h: 0.0,
|
||||
dot: false,
|
||||
});
|
||||
return;
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster.
|
||||
|
|
@ -362,7 +377,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
let (start, end) = (range.start + i, range.start + i + c.len_utf8());
|
||||
let si = b.style(&rc, None, false);
|
||||
let g = b.glyphs.len() as u32;
|
||||
let mut push = |b: &mut Builder, kind: ClKind, adv: f32, h: f32| {
|
||||
let push = |b: &mut Builder, kind: ClKind, adv: f32, h: f32| {
|
||||
b.clusters.push(Cluster { start, end, adv, kind, style: si, g0: g, g1: g, break_after: false, obj_h: h, dot: false })
|
||||
};
|
||||
match c {
|
||||
|
|
@ -687,13 +702,14 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
|||
any = true;
|
||||
}
|
||||
}
|
||||
if !any || (asc == 0.0 && desc == 0.0) {
|
||||
if let Some(st) = pl.styles.get(mark_style as usize) {
|
||||
asc = st.ascent;
|
||||
desc = st.descent;
|
||||
}
|
||||
if (!any || (asc == 0.0 && desc == 0.0))
|
||||
&& let Some(st) = pl.styles.get(mark_style as usize)
|
||||
{
|
||||
asc = st.ascent;
|
||||
desc = st.descent;
|
||||
}
|
||||
if first && let Some(lab) = &pl.label
|
||||
if first
|
||||
&& let Some(lab) = &pl.label
|
||||
&& let Some(st) = pl.styles.get(lab.style as usize)
|
||||
{
|
||||
asc = asc.max(st.ascent);
|
||||
|
|
@ -767,7 +783,20 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
|||
}
|
||||
let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len);
|
||||
let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start };
|
||||
lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left: if first { first_left } else { rp.indent_left }, right: right_edge });
|
||||
lines.push(Line {
|
||||
top,
|
||||
height,
|
||||
baseline,
|
||||
c0,
|
||||
c1,
|
||||
xs,
|
||||
leaders,
|
||||
end,
|
||||
start,
|
||||
stop,
|
||||
left: if first { first_left } else { rp.indent_left },
|
||||
right: right_edge,
|
||||
});
|
||||
top += height;
|
||||
first = false;
|
||||
i = j;
|
||||
|
|
@ -904,8 +933,11 @@ fn proof_issues(p: &Paragraph) -> Vec<(usize, usize, bool)> {
|
|||
return Vec::new();
|
||||
}
|
||||
let text: String = p.text.chars().map(|c| if c == OBJ { ' ' } else { c }).collect();
|
||||
let skip = |a: usize, b: usize| p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some()));
|
||||
let mut v: Vec<(usize, usize, bool)> = wordcraft_proof::check_spelling(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, false)).collect();
|
||||
let skip = |a: usize, b: usize| {
|
||||
p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some()))
|
||||
};
|
||||
let mut v: Vec<(usize, usize, bool)> =
|
||||
wordcraft_proof::check_spelling(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, false)).collect();
|
||||
v.extend(wordcraft_proof::check_grammar(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, true)));
|
||||
v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub struct RowLayout {
|
|||
pub struct TableLayout {
|
||||
/// Table x offset within the column.
|
||||
pub x: f32,
|
||||
#[allow(dead_code)]
|
||||
pub width: f32,
|
||||
pub rows: Vec<RowLayout>,
|
||||
}
|
||||
|
|
@ -33,7 +34,11 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let parts = style_parts(ctx, t);
|
||||
let ncols = t.cols().max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols { t.grid.iter().map(|w| if w.is_finite() { w.max(4.0) } else { 72.0 }).collect() } else { vec![avail / ncols as f32; ncols] };
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
t.grid.iter().map(|w| if w.is_finite() { w.max(4.0) } else { 72.0 }).collect()
|
||||
} else {
|
||||
vec![avail / ncols as f32; ncols]
|
||||
};
|
||||
let mut total: f32 = grid.iter().sum();
|
||||
let target = match (t.props.width_pct, t.props.width) {
|
||||
(Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(1.0)),
|
||||
|
|
@ -129,7 +134,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
}
|
||||
// Effective borders: cell > table (outer vs inside).
|
||||
let tb = tborders.unwrap_or_default();
|
||||
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 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 mut borders = Borders {
|
||||
top: edge(cb.top, ri == 0, tb.top, tb.between),
|
||||
|
|
@ -142,7 +148,19 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
if is_total && let Some(b) = parts.as_ref().and_then(|p| p.total_border_top) {
|
||||
borders.top = Some(b);
|
||||
}
|
||||
cells.push(CellBox { items, h, x: x0, w: x1 - x0, valign: cell.props.valign, fill, borders, vmerge: cell.props.vmerge, margins, g, span });
|
||||
cells.push(CellBox {
|
||||
items,
|
||||
h,
|
||||
x: x0,
|
||||
w: x1 - x0,
|
||||
valign: cell.props.valign,
|
||||
fill,
|
||||
borders,
|
||||
vmerge: cell.props.vmerge,
|
||||
margins,
|
||||
g,
|
||||
span,
|
||||
});
|
||||
g += span;
|
||||
}
|
||||
let rh = match (row.props.height, row.props.height_rule) {
|
||||
|
|
|
|||
|
|
@ -213,7 +213,8 @@ fn hostile_props_do_not_panic() {
|
|||
|
||||
#[test]
|
||||
fn layout_is_fast() {
|
||||
let d = Document::from_text(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n".repeat(3000));
|
||||
let d =
|
||||
Document::from_text(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n".repeat(3000));
|
||||
let mut c = LayoutCache::new();
|
||||
let t0 = now_ms();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
|
|
@ -242,14 +243,31 @@ fn footnotes_sit_at_page_bottom() {
|
|||
let mut note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default()).styled("FootnoteText");
|
||||
note.props.space_after = Some(0.0);
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(&Pos::body(3, 4), InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() }, &Default::default()).unwrap();
|
||||
let eid = d.add_part(wordcraft_doc::PartKind::Endnote, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))]);
|
||||
d.insert_object(&Pos::body(5, 2), InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() }, &Default::default()).unwrap();
|
||||
d.insert_object(
|
||||
&Pos::body(3, 4),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let eid = d.add_part(
|
||||
wordcraft_doc::PartKind::Endnote,
|
||||
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))],
|
||||
);
|
||||
d.insert_object(
|
||||
&Pos::body(5, 2),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let l = lay(&d);
|
||||
let c = l.caret_on(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, 0).unwrap();
|
||||
assert_eq!(c.page, 0);
|
||||
assert!(c.top > 650.0 && c.top < 720.0, "{c:?}");
|
||||
let body_bottom = l.pages[0].items.iter().filter_map(|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }).fold(0.0f32, f32::max);
|
||||
let body_bottom = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { story: StoryRef::Body, y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None })
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(body_bottom < c.top, "{body_bottom} {}", c.top);
|
||||
assert!(l.caret_on(&Pos { story: StoryRef::Part(eid), path: Path::top(0), off: 0 }, 0).is_some());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ impl Backend for Headless {
|
|||
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default());
|
||||
let png = img.to_png();
|
||||
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
|
||||
Ok(
|
||||
json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}),
|
||||
)
|
||||
}
|
||||
m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")),
|
||||
other => self.session.run(other, ¶ms).map_err(|e| e.to_string()),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ impl ToolResult {
|
|||
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true }
|
||||
}
|
||||
pub fn image(png_base64: String, info: &Value) -> Self {
|
||||
Self { content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})], is_error: false }
|
||||
Self {
|
||||
content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})],
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({"content": self.content, "isError": self.is_error})
|
||||
|
|
@ -43,22 +46,103 @@ fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value {
|
|||
|
||||
pub fn tool_definitions() -> Value {
|
||||
json!([
|
||||
tool("list_commands", "List commands", "Every WordCraft command: id, label, ribbon location, shortcut and params. Filter with `query`.", obj(json!({"query": s("Case-insensitive filter on id/label/location")}), &[]), true),
|
||||
tool("execute", "Run a command", "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.", obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]), false),
|
||||
tool("batch", "Run several commands", "Run commands in order; stops at the first error unless keepGoing.", obj(json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}), &["commands"]), false),
|
||||
tool("new_document", "New document", "Start a document: blank, sample, letter, resume or report.", obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]), false),
|
||||
tool("open_document", "Open", "Open a .docx, .odt, .rtf, .md, .html, .txt or .json file.", obj(json!({"path": s("File path")}), &["path"]), false),
|
||||
tool("save_document", "Save", "Save (format from the extension: docx, pdf, odt, rtf, html, md, txt, png).", obj(json!({"path": s("File path; omit to save in place")}), &[]), false),
|
||||
tool("type_text", "Type text", "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false),
|
||||
tool("select_text", "Select text", "Select the n-th occurrence of some text.", obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), false),
|
||||
tool(
|
||||
"list_commands",
|
||||
"List commands",
|
||||
"Every WordCraft command: id, label, ribbon location, shortcut and params. Filter with `query`.",
|
||||
obj(json!({"query": s("Case-insensitive filter on id/label/location")}), &[]),
|
||||
true
|
||||
),
|
||||
tool(
|
||||
"execute",
|
||||
"Run a command",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.",
|
||||
obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands",
|
||||
"Run commands in order; stops at the first error unless keepGoing.",
|
||||
obj(
|
||||
json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}),
|
||||
&["commands"]
|
||||
),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"new_document",
|
||||
"New document",
|
||||
"Start a document: blank, sample, letter, resume or report.",
|
||||
obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"open_document",
|
||||
"Open",
|
||||
"Open a .docx, .odt, .rtf, .md, .html, .txt or .json file.",
|
||||
obj(json!({"path": s("File path")}), &["path"]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"save_document",
|
||||
"Save",
|
||||
"Save (format from the extension: docx, pdf, odt, rtf, html, md, txt, png).",
|
||||
obj(json!({"path": s("File path; omit to save in place")}), &[]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"type_text",
|
||||
"Type text",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
|
||||
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"select_text",
|
||||
"Select text",
|
||||
"Select the n-th occurrence of some text.",
|
||||
obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]),
|
||||
false
|
||||
),
|
||||
tool("get_text", "Document text", "Plain text of the document body.", obj(json!({}), &[]), true),
|
||||
tool("inspect_document", "Inspect document", "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.", obj(json!({}), &[]), true),
|
||||
tool("render_page", "Render page", "Render a page to PNG to look at the result.", obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]), true),
|
||||
tool(
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.",
|
||||
obj(json!({}), &[]),
|
||||
true
|
||||
),
|
||||
tool(
|
||||
"render_page",
|
||||
"Render page",
|
||||
"Render a page to PNG to look at the result.",
|
||||
obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]),
|
||||
true
|
||||
),
|
||||
tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true),
|
||||
tool("screenshot", "Screenshot app", "Screenshot of the whole WordCraft window (desktop app only).", obj(json!({}), &[]), true),
|
||||
tool("click", "Click", "Click at window coordinates (desktop app only).", obj(json!({"x": n("x"), "y": n("y"), "count": n("1-3 clicks"), "shift": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["x", "y"]), false),
|
||||
tool("key", "Press key", "Press a key with modifiers (desktop app only), e.g. {key:\"B\", cmd:true}.", obj(json!({"key": s("Key name"), "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["key"]), false),
|
||||
tool("ui_inspect", "Inspect UI", "UI state: tab, panes, dialog, page rectangles on screen, caret (desktop app only).", obj(json!({}), &[]), true),
|
||||
tool(
|
||||
"click",
|
||||
"Click",
|
||||
"Click at window coordinates (desktop app only).",
|
||||
obj(json!({"x": n("x"), "y": n("y"), "count": n("1-3 clicks"), "shift": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["x", "y"]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"key",
|
||||
"Press key",
|
||||
"Press a key with modifiers (desktop app only), e.g. {key:\"B\", cmd:true}.",
|
||||
obj(json!({"key": s("Key name"), "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}), &["key"]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"ui_inspect",
|
||||
"Inspect UI",
|
||||
"UI state: tab, panes, dialog, page rectangles on screen, caret (desktop app only).",
|
||||
obj(json!({}), &[]),
|
||||
true
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +166,12 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
|||
Ok(Value::Array(v)) => {
|
||||
let f: Vec<Value> = v
|
||||
.into_iter()
|
||||
.filter(|c| q.is_empty() || ["id", "label", "location"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|x| x.to_lowercase().contains(&q))))
|
||||
.filter(|c| {
|
||||
q.is_empty()
|
||||
|| ["id", "label", "location"]
|
||||
.iter()
|
||||
.any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|x| x.to_lowercase().contains(&q)))
|
||||
})
|
||||
.collect();
|
||||
ToolResult::json(&Value::Array(f))
|
||||
}
|
||||
|
|
@ -119,10 +208,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
|||
if ext == "png" {
|
||||
wrap(exec(b, "file.exportPng", json!({"path": p})))
|
||||
} else {
|
||||
wrap(exec(b, "file.save", match p {
|
||||
Some(p) => json!({"path": p}),
|
||||
None => json!({}),
|
||||
}))
|
||||
wrap(exec(
|
||||
b,
|
||||
"file.save",
|
||||
match p {
|
||||
Some(p) => json!({"path": p}),
|
||||
None => json!({}),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
"type_text" => {
|
||||
|
|
@ -145,7 +238,9 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
|||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
|
||||
}
|
||||
}
|
||||
"select_text" => wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}))),
|
||||
"select_text" => {
|
||||
wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))})))
|
||||
}
|
||||
"get_text" => wrap(exec(b, "document.text", json!({}))),
|
||||
"inspect_document" => wrap(b.call("document.inspect", json!({}))),
|
||||
"parity" => wrap(b.call("ui.parity", json!({}))),
|
||||
|
|
|
|||
|
|
@ -52,11 +52,80 @@ pub fn user_dictionary() -> Vec<String> {
|
|||
const COMMON: &str = "a an the and or but if of to in on at by for with from as is am are was were be been being it its we us our you your he him his she her they them their i me my mine this that these those there here what which who whom whose when where why how all any both each few more most other some such no nor not only own same so than too very can will just should now do does did done has have had having may might must shall would could up down out over under again further then once off into onto upon about above below between through during before after until while because per via vs etc ok yes yeah oh ah hi hey bye one two three four five six seven eight nine ten first last next new old get got go goes went gone see saw seen say said make made take took come came give gave know knew think thought look want need use used find found tell told ask asked feel felt try left keep let put mean set run show turn move live play pay hear hold bring begin seem help talk start might";
|
||||
|
||||
const EXTRA: &[&str] = &[
|
||||
"don't", "doesn't", "didn't", "can't", "won't", "isn't", "aren't", "wasn't", "weren't", "shouldn't", "wouldn't", "couldn't", "i'm", "i've", "i'd", "i'll",
|
||||
"you're", "you've", "you'll", "you'd", "we're", "we've", "we'll", "they're", "they've", "they'll", "it's", "that's", "there's", "here's", "what's", "let's",
|
||||
"email", "emails", "online", "website", "websites", "internet", "software", "app", "apps", "smartphone", "blog", "login", "logout", "username", "wifi",
|
||||
"toolbar", "workflow", "workflows", "dataset", "datasets", "metadata", "startup", "startups", "covid", "selfie", "podcast", "podcasts", "hashtag",
|
||||
"wordcraft", "artcraft", "discord", "github", "rust", "ok", "okay", "unlabelled", "labelled", "colour", "colours", "favourite", "centre", "organise",
|
||||
"don't",
|
||||
"doesn't",
|
||||
"didn't",
|
||||
"can't",
|
||||
"won't",
|
||||
"isn't",
|
||||
"aren't",
|
||||
"wasn't",
|
||||
"weren't",
|
||||
"shouldn't",
|
||||
"wouldn't",
|
||||
"couldn't",
|
||||
"i'm",
|
||||
"i've",
|
||||
"i'd",
|
||||
"i'll",
|
||||
"you're",
|
||||
"you've",
|
||||
"you'll",
|
||||
"you'd",
|
||||
"we're",
|
||||
"we've",
|
||||
"we'll",
|
||||
"they're",
|
||||
"they've",
|
||||
"they'll",
|
||||
"it's",
|
||||
"that's",
|
||||
"there's",
|
||||
"here's",
|
||||
"what's",
|
||||
"let's",
|
||||
"email",
|
||||
"emails",
|
||||
"online",
|
||||
"website",
|
||||
"websites",
|
||||
"internet",
|
||||
"software",
|
||||
"app",
|
||||
"apps",
|
||||
"smartphone",
|
||||
"blog",
|
||||
"login",
|
||||
"logout",
|
||||
"username",
|
||||
"wifi",
|
||||
"toolbar",
|
||||
"workflow",
|
||||
"workflows",
|
||||
"dataset",
|
||||
"datasets",
|
||||
"metadata",
|
||||
"startup",
|
||||
"startups",
|
||||
"covid",
|
||||
"selfie",
|
||||
"podcast",
|
||||
"podcasts",
|
||||
"hashtag",
|
||||
"wordcraft",
|
||||
"artcraft",
|
||||
"discord",
|
||||
"github",
|
||||
"rust",
|
||||
"ok",
|
||||
"okay",
|
||||
"unlabelled",
|
||||
"labelled",
|
||||
"colour",
|
||||
"colours",
|
||||
"favourite",
|
||||
"centre",
|
||||
"organise",
|
||||
];
|
||||
|
||||
fn known_core(w: &str) -> bool {
|
||||
|
|
@ -94,12 +163,13 @@ fn known_core(w: &str) -> bool {
|
|||
}
|
||||
// Doubled consonant: running → run, stopped → stop.
|
||||
let b = stem.as_bytes();
|
||||
if (suf == "ing" || suf == "ed" || suf == "er" || suf == "est") && b.len() >= 3 && b.get(b.len() - 1) == b.get(b.len() - 2) {
|
||||
if let Some(s) = stem.get(..stem.len() - 1)
|
||||
&& d.get(s).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (suf == "ing" || suf == "ed" || suf == "er" || suf == "est")
|
||||
&& b.len() >= 3
|
||||
&& b.last() == b.get(b.len() - 2)
|
||||
&& let Some(s) = stem.get(..stem.len() - 1)
|
||||
&& d.get(s).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -195,7 +265,9 @@ fn damerau(a: &[char], b: &str) -> usize {
|
|||
for j in 1..=m {
|
||||
let (ai, bj) = (a.get(i - 1), b.get(j - 1));
|
||||
let cost = usize::from(ai != bj);
|
||||
let mut v = (prev.get(j).copied().unwrap_or(9) + 1).min(cur.get(j - 1).copied().unwrap_or(9) + 1).min(prev.get(j - 1).copied().unwrap_or(9) + cost);
|
||||
let mut v = (prev.get(j).copied().unwrap_or(9) + 1)
|
||||
.min(cur.get(j - 1).copied().unwrap_or(9) + 1)
|
||||
.min(prev.get(j - 1).copied().unwrap_or(9) + cost);
|
||||
if i > 1 && j > 1 && a.get(i - 1) == b.get(j - 2) && a.get(i - 2) == b.get(j - 1) {
|
||||
v = v.min(prev2.get(j - 2).copied().unwrap_or(9) + 1);
|
||||
}
|
||||
|
|
@ -268,14 +340,40 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
|||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if gap.trim().is_empty() && w0.eq_ignore_ascii_case(w1) && w0.chars().all(char::is_alphabetic) && !matches!(w0.to_lowercase().as_str(), "had" | "that" | "bye" | "so") {
|
||||
v.push(Issue { start: *a0, end: *b1, kind: IssueKind::Grammar, message: format!("Repeated word: \"{w1}\""), suggestions: vec![w0.to_string()] });
|
||||
if gap.trim().is_empty()
|
||||
&& w0.eq_ignore_ascii_case(w1)
|
||||
&& w0.chars().all(char::is_alphabetic)
|
||||
&& !matches!(w0.to_lowercase().as_str(), "had" | "that" | "bye" | "so")
|
||||
{
|
||||
v.push(Issue {
|
||||
start: *a0,
|
||||
end: *b1,
|
||||
kind: IssueKind::Grammar,
|
||||
message: format!("Repeated word: \"{w1}\""),
|
||||
suggestions: vec![w0.to_string()],
|
||||
});
|
||||
}
|
||||
if gap == " " && (w0 == "a" || w0 == "A") && w1.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) && !starts_consonant_sound(w1) {
|
||||
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"an\" before a vowel sound".into(), suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()] });
|
||||
v.push(Issue {
|
||||
start: *a0,
|
||||
end: *b0,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Use \"an\" before a vowel sound".into(),
|
||||
suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()],
|
||||
});
|
||||
}
|
||||
if gap == " " && (w0 == "an" || w0 == "An") && w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c)) && !w1.chars().all(|c| c.is_uppercase()) {
|
||||
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"a\" before a consonant sound".into(), suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()] });
|
||||
if gap == " "
|
||||
&& (w0 == "an" || w0 == "An")
|
||||
&& w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c))
|
||||
&& !w1.chars().all(|c| c.is_uppercase())
|
||||
{
|
||||
v.push(Issue {
|
||||
start: *a0,
|
||||
end: *b0,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Use \"a\" before a consonant sound".into(),
|
||||
suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Space before punctuation; doubled spaces.
|
||||
|
|
@ -283,7 +381,13 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
|||
if text.get(i + 2..).is_some_and(|r| r.starts_with('.') || r.starts_with(',')) {
|
||||
continue; // "..." or numbers like " .5"
|
||||
}
|
||||
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Remove the space before the punctuation".into(), suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()] });
|
||||
v.push(Issue {
|
||||
start: i,
|
||||
end: i + 2,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Remove the space before the punctuation".into(),
|
||||
suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()],
|
||||
});
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
|
|
@ -297,7 +401,13 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
|||
let Some(w) = text.get(*a..*b) else { continue };
|
||||
if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||
let fixed: String = w.chars().next().map(|f| f.to_uppercase().chain(w.chars().skip(1)).collect()).unwrap_or_default();
|
||||
v.push(Issue { start: *a, end: *b, kind: IssueKind::Grammar, message: "Capitalize the first word of a sentence".into(), suggestions: vec![fixed] });
|
||||
v.push(Issue {
|
||||
start: *a,
|
||||
end: *b,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Capitalize the first word of a sentence".into(),
|
||||
suggestions: vec![fixed],
|
||||
});
|
||||
}
|
||||
let tail = text.get(*b..).unwrap_or("");
|
||||
let next_non_space = tail.trim_start_matches(['"', '”', ')', '\'']).chars().next();
|
||||
|
|
@ -323,7 +433,23 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn spelling() {
|
||||
for w in ["if", "we", "hello", "world", "running", "studios", "don't", "Thursday", "NASA", "2026", "co-operate", "unhappy", "colour", "it's", "stopped"] {
|
||||
for w in [
|
||||
"if",
|
||||
"we",
|
||||
"hello",
|
||||
"world",
|
||||
"running",
|
||||
"studios",
|
||||
"don't",
|
||||
"Thursday",
|
||||
"NASA",
|
||||
"2026",
|
||||
"co-operate",
|
||||
"unhappy",
|
||||
"colour",
|
||||
"it's",
|
||||
"stopped",
|
||||
] {
|
||||
assert!(is_correct(w), "{w}");
|
||||
}
|
||||
for w in ["helo", "wrold", "sentense", "recieve"] {
|
||||
|
|
@ -366,7 +492,7 @@ mod tests {
|
|||
#[test]
|
||||
fn hyphenation() {
|
||||
let l = hyphen::Limits::default();
|
||||
assert_eq!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2, true);
|
||||
assert!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ impl Patterns {
|
|||
true
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u64, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pub struct Rendered {
|
|||
impl Rendered {
|
||||
pub fn to_straight(&self) -> Vec<u8> {
|
||||
let mut out = self.pixels.clone();
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
for px in out.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
|
|
@ -56,7 +56,9 @@ impl Rendered {
|
|||
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
|
||||
let rgba = self.to_straight();
|
||||
let rgb: Vec<u8> = rgba
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let a = p[3] as u32;
|
||||
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
|
||||
|
|
@ -193,7 +195,9 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
|
|||
let size = (diag * 0.7 / raw_w).min(300.0);
|
||||
let k = size / upem;
|
||||
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / page.w.max(1.0) as f64).atan() } else { 0.0 };
|
||||
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0)) * Affine::rotate(angle) * Affine::translate((-raw_w * size / 2.0, size * 0.35));
|
||||
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0))
|
||||
* Affine::rotate(angle)
|
||||
* Affine::translate((-raw_w * size / 2.0, size * 0.35));
|
||||
ctx.set_paint(color(wm.color, if wm.semitransparent { 0.5 } else { 1.0 }));
|
||||
let db = FontDb::global();
|
||||
let mut x = 0.0;
|
||||
|
|
@ -374,7 +378,15 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
|
|||
}
|
||||
ShapeKind::Arrow => {
|
||||
let h = r.height();
|
||||
poly(&[(r.x0, cy - h * 0.2), (r.x1 - h * 0.5, cy - h * 0.2), (r.x1 - h * 0.5, r.y0), (r.x1, cy), (r.x1 - h * 0.5, r.y1), (r.x1 - h * 0.5, cy + h * 0.2), (r.x0, cy + h * 0.2)])
|
||||
poly(&[
|
||||
(r.x0, cy - h * 0.2),
|
||||
(r.x1 - h * 0.5, cy - h * 0.2),
|
||||
(r.x1 - h * 0.5, r.y0),
|
||||
(r.x1, cy),
|
||||
(r.x1 - h * 0.5, r.y1),
|
||||
(r.x1 - h * 0.5, cy + h * 0.2),
|
||||
(r.x0, cy + h * 0.2),
|
||||
])
|
||||
}
|
||||
ShapeKind::Star => {
|
||||
let mut pts = Vec::new();
|
||||
|
|
@ -399,6 +411,15 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render the page area (`x`, `y`, `w`×`h` points) at `scale` px/pt — for previews and thumbnails.
|
||||
pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let pw = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let ph = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
|
||||
render_region(doc, page, pw, ph, view, opts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -436,12 +457,3 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the page area (`x`, `y`, `w`×`h` points) at `scale` px/pt — for previews and thumbnails.
|
||||
pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let pw = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let ph = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
|
||||
render_region(doc, page, pw, ph, view, opts)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,18 +17,19 @@ fn main() {
|
|||
let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl");
|
||||
let script = std::fs::read_to_string(&path).expect("read script");
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ControlRequest>();
|
||||
let lines: Vec<serde_json::Value> = script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect();
|
||||
let doc = if lines.first().is_some_and(|l| l.get("empty").is_some()) { wordcraft_doc::Document::new() } else { wordcraft_engine::sample::sample_document() };
|
||||
let lines: Vec<serde_json::Value> =
|
||||
script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect();
|
||||
let doc = if lines.first().is_some_and(|l| l.get("empty").is_some()) {
|
||||
wordcraft_doc::Document::new()
|
||||
} else {
|
||||
wordcraft_engine::sample::sample_document()
|
||||
};
|
||||
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
|
||||
app.integrated_titlebar = true;
|
||||
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
|
||||
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
|
||||
let mut harness = egui_kittest::Harness::builder()
|
||||
.with_size(egui::vec2(w, h))
|
||||
.with_pixels_per_point(2.0)
|
||||
.with_max_steps(1_000_000)
|
||||
.wgpu()
|
||||
.build_ui_state(
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -20,53 +20,68 @@ const PAGES: [(&str, &str); 9] = [
|
|||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::left("backstage_nav").exact_size(200.0).frame(egui::Frame::NONE.fill(APP_COLOR).inner_margin(egui::Margin { left: 0, right: 0, top: 12, bottom: 12 })).show(ui, |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::click());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for (id, label) in PAGES {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click());
|
||||
let active = app.ui.backstage_page == id;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
egui::Panel::left("backstage_nav")
|
||||
.exact_size(200.0)
|
||||
.frame(egui::Frame::NONE.fill(APP_COLOR).inner_margin(egui::Margin { left: 0, right: 0, top: 12, bottom: 12 }))
|
||||
.show(ui, |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::click());
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(18.0, 18.0)),
|
||||
"chevronLeft",
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, if active { semibold(14.0) } else { medium(14.0) }, egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
match id {
|
||||
"save" => {
|
||||
let _ = app.run("file.save", json!({}));
|
||||
}
|
||||
"saveAs" => app.save_as_dialog(),
|
||||
"open" => {
|
||||
app.ui.backstage_page = id.into();
|
||||
}
|
||||
_ => app.ui.backstage_page = id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.add_space(6.0);
|
||||
for (id, label) in PAGES {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click());
|
||||
let active = app.ui.backstage_page == id;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(13.0), egui::Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 22.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
if active { semibold(14.0) } else { medium(14.0) },
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
if resp.clicked() {
|
||||
if id == "discord" {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
} else {
|
||||
app.dialog = crate::dialogs::Dialog::open("about", app);
|
||||
match id {
|
||||
"save" => {
|
||||
let _ = app.run("file.save", json!({}));
|
||||
}
|
||||
"saveAs" => app.save_as_dialog(),
|
||||
"open" => {
|
||||
app.ui.backstage_page = id.into();
|
||||
}
|
||||
_ => app.ui.backstage_page = id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(13.0), egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
if id == "discord" {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
} else {
|
||||
app.dialog = crate::dialogs::Dialog::open("about", app);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"new" | "home" => new_page(app, ui),
|
||||
|
|
@ -88,7 +103,13 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
|||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
|
||||
ui.painter().rect(r, 2.0, egui::Color32::WHITE, Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
ui.painter().rect(
|
||||
r,
|
||||
2.0,
|
||||
egui::Color32::WHITE,
|
||||
Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let key = format!("tpl:{template}:{ppp}");
|
||||
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::new(&key))).or_else(|| {
|
||||
|
|
@ -98,7 +119,11 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
|||
let page = l.pages.first()?;
|
||||
let img = wordcraft_render::render_page(&s.doc, page, 150.0 / page.w * ppp, &Default::default());
|
||||
let px = img.to_straight();
|
||||
let h = ui.ctx().load_texture(&key, egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px), egui::TextureOptions::LINEAR);
|
||||
let h = ui.ctx().load_texture(
|
||||
&key,
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px),
|
||||
egui::TextureOptions::LINEAR,
|
||||
);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
Some(h)
|
||||
});
|
||||
|
|
@ -116,7 +141,16 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
|||
|
||||
fn new_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
let hour = wordcraft_engine::cmd::now_iso().get(11..13).and_then(|h| h.parse::<u32>().ok()).unwrap_or(9);
|
||||
heading(ui, if hour < 12 { "Good morning" } else if hour < 18 { "Good afternoon" } else { "Good evening" });
|
||||
heading(
|
||||
ui,
|
||||
if hour < 12 {
|
||||
"Good morning"
|
||||
} else if hour < 18 {
|
||||
"Good afternoon"
|
||||
} else {
|
||||
"Good evening"
|
||||
},
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(18.0, 0.0);
|
||||
template_tile(ui, app, "Blank document", "blank");
|
||||
|
|
@ -193,7 +227,16 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) {
|
|||
heading(ui, if app.ui.backstage_page == "print" { "Print" } else { "Export" });
|
||||
ui.label("Save a copy in another format. Printing goes through a PDF you can print from any viewer.");
|
||||
ui.add_space(12.0);
|
||||
for (label, ext) in [("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), ("Markdown (*.md)", "md"), ("Plain text (*.txt)", "txt"), ("Page image (*.png)", "png")] {
|
||||
for (label, ext) in [
|
||||
("PDF document (*.pdf)", "pdf"),
|
||||
("Word document (*.docx)", "docx"),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
("Markdown (*.md)", "md"),
|
||||
("Plain text (*.txt)", "txt"),
|
||||
("Page image (*.png)", "png"),
|
||||
] {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(label).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
|
||||
let name = format!("{}.{ext}", app.title_stem());
|
||||
let picked = app.services.pick_save.as_ref().and_then(|f| f(&name));
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
let key = page_key(app, page, scale_px);
|
||||
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
if !fresh && (rendered < 2 || app.canvas.textures.get(&i).is_none() && rendered < 4) {
|
||||
if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.marks = app.session.view.marks;
|
||||
opts.display.markup = app.session.view.show_markup;
|
||||
|
|
@ -242,7 +242,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
if app.session.view.gridlines {
|
||||
for it in &page.items {
|
||||
if let Placed::Cell { rect, .. } = it {
|
||||
let r = Rect::from_min_size(pos2(sr.min.x + rect.x * geo.scale, sr.min.y + rect.y * geo.scale), vec2(rect.w * geo.scale, rect.h * geo.scale));
|
||||
let r = Rect::from_min_size(
|
||||
pos2(sr.min.x + rect.x * geo.scale, sr.min.y + rect.y * geo.scale),
|
||||
vec2(rect.w * geo.scale, rect.h * geo.scale),
|
||||
);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
|
|
@ -260,7 +263,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let (a, b) = app.session.sel.ordered();
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale));
|
||||
let sr =
|
||||
Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +278,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let y0 = pr.min.y + c.top * geo.scale;
|
||||
let y1 = y0 + c.height * geo.scale;
|
||||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||||
let on = (since / 530.0) as u64 % 2 == 0;
|
||||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||||
if app.session.sel.is_collapsed() && on && focused {
|
||||
painter.line_segment([pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
|
|
@ -285,7 +289,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1));
|
||||
if focused {
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false });
|
||||
o.ime =
|
||||
Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
if !app.canvas.ime_preedit.is_empty() {
|
||||
|
|
@ -302,7 +307,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
app.canvas.want_focus = false;
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
}
|
||||
app.canvas.focused = resp.has_focus();
|
||||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||||
|
|
@ -392,14 +399,25 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
if pressed {
|
||||
// Clicking into a footnote/endnote edits it; clicking the body from a note goes back.
|
||||
let story = match layout.story_at(page, x, y) {
|
||||
Some(StoryRef::Part(id)) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote)) => StoryRef::Part(id),
|
||||
Some(StoryRef::Body) if matches!(story, StoryRef::Part(id) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote))) => StoryRef::Body,
|
||||
Some(StoryRef::Part(id))
|
||||
if app
|
||||
.session
|
||||
.doc
|
||||
.parts
|
||||
.get(&id)
|
||||
.is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote)) =>
|
||||
{
|
||||
StoryRef::Part(id)
|
||||
}
|
||||
Some(StoryRef::Body) if matches!(story, StoryRef::Part(id) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote))) => {
|
||||
StoryRef::Body
|
||||
}
|
||||
_ => story,
|
||||
};
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
&& let Some(link) = app.session.doc.para_at(&pos).map(|pp| pp.props_of_char(pos.off).link.clone()).flatten()
|
||||
&& let Some(link) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone())
|
||||
{
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
let _ = app.run("edit.goto", json!({"bookmark": name}));
|
||||
|
|
@ -471,7 +489,15 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
}
|
||||
if xpt >= 0.0 {
|
||||
let sx = x0 + xpt * scale;
|
||||
let (len, label) = if k % 8 == 0 { (0.0, Some(k / 8)) } else if k % 4 == 0 { (5.0, None) } else if k % 2 == 0 { (3.0, None) } else { (1.5, None) };
|
||||
let (len, label) = if k % 8 == 0 {
|
||||
(0.0, Some(k / 8))
|
||||
} else if k % 4 == 0 {
|
||||
(5.0, None)
|
||||
} else if k % 2 == 0 {
|
||||
(3.0, None)
|
||||
} else {
|
||||
(1.5, None)
|
||||
};
|
||||
match label {
|
||||
Some(n) if n != 0 => {
|
||||
hp.text(pos2(sx, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick);
|
||||
|
|
@ -497,10 +523,28 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
let left = x0 + (col_x + rp.indent_left) * scale;
|
||||
let right = x0 + (col_x + page.body.w - rp.indent_right) * scale;
|
||||
let c = t.text_dim;
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y - 3.0), pos2(left, bar.max.y - 8.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
hp.rect(Rect::from_min_max(pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y + 1.0)), 0.0, t.ruler, Stroke::new(1.0, c), egui::StrokeKind::Inside);
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(right - 4.5, bar.max.y - 1.0), pos2(right + 4.5, bar.max.y - 1.0), pos2(right, bar.max.y - 6.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
hp.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)],
|
||||
t.ruler,
|
||||
Stroke::new(1.0, c),
|
||||
));
|
||||
hp.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y - 3.0), pos2(left, bar.max.y - 8.0)],
|
||||
t.ruler,
|
||||
Stroke::new(1.0, c),
|
||||
));
|
||||
hp.rect(
|
||||
Rect::from_min_max(pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y + 1.0)),
|
||||
0.0,
|
||||
t.ruler,
|
||||
Stroke::new(1.0, c),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
hp.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(right - 4.5, bar.max.y - 1.0), pos2(right + 4.5, bar.max.y - 1.0), pos2(right, bar.max.y - 6.0)],
|
||||
t.ruler,
|
||||
Stroke::new(1.0, c),
|
||||
));
|
||||
for tab in &rp.tabs {
|
||||
let tx = x0 + (col_x + tab.pos) * scale;
|
||||
hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
|
|
@ -592,7 +636,11 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
};
|
||||
if let Some(issue) = app.canvas.context_issue.clone() {
|
||||
let sugg: Vec<String> = issue.get("suggestions").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
let sugg: Vec<String> = issue
|
||||
.get("suggestions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new("(no suggestions)").italics());
|
||||
|
|
|
|||
|
|
@ -25,168 +25,210 @@ fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, e
|
|||
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let left = if app.integrated_titlebar { 78 } else { 8 };
|
||||
egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })).show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
// Drag the window by the title bar (integrated title bar on macOS).
|
||||
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
if drag.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let mut qat_end = full.min.x;
|
||||
let mut right_start = full.max.x;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave toggle (on when the document has a path).
|
||||
let has_path = app.session.path.is_some();
|
||||
ui.label(egui::RichText::new("AutoSave").font(regular(11.5)).color(t.text_dim));
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let on = has_path && app.canvas_autosave();
|
||||
ui.painter().rect(r, 8.0, if on { t.accent } else { t.input }, Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim });
|
||||
if resp.on_hover_text("AutoSave saves every change to the file (needs a saved document)").clicked() {
|
||||
if has_path {
|
||||
app.toggle_autosave();
|
||||
} else {
|
||||
app.save_as_dialog();
|
||||
}
|
||||
egui::Panel::top("title_bar")
|
||||
.exact_size(38.0)
|
||||
.frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
// Drag the window by the title bar (integrated title bar on macOS).
|
||||
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
qat_button(ui, app, "save", "Save", "file.save", true);
|
||||
let can_undo = app.session.can_undo();
|
||||
let can_redo = app.session.can_redo();
|
||||
qat_button(ui, app, "undo", &format!("Undo {}", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo);
|
||||
qat_button(ui, app, "redo", "Redo", "edit.redo", can_redo);
|
||||
qat_button(ui, app, "more", "Customize Quick Access Toolbar", "ui.dialog", true);
|
||||
qat_end = ui.min_rect().max.x;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 12.0, t.accent);
|
||||
let initials: String = app.session.author.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE);
|
||||
resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author));
|
||||
ui.add_space(8.0);
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { egui::Color32::from_rgb(0x47, 0x52, 0xC4) } else { egui::Color32::from_rgb(0x58, 0x65, 0xF2) });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)), "discord", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, "Discord", medium(11.5), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Join the ArtCraft community on Discord").clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
if drag.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let mut qat_end = full.min.x;
|
||||
let mut right_start = full.max.x;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave toggle (on when the document has a path).
|
||||
let has_path = app.session.path.is_some();
|
||||
ui.label(egui::RichText::new("AutoSave").font(regular(11.5)).color(t.text_dim));
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let on = has_path && app.canvas_autosave();
|
||||
ui.painter().rect(
|
||||
r,
|
||||
8.0,
|
||||
if on { t.accent } else { t.input },
|
||||
Stroke::new(1.0, if on { t.accent } else { t.border_strong }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim });
|
||||
if resp.on_hover_text("AutoSave saves every change to the file (needs a saved document)").clicked() {
|
||||
if has_path {
|
||||
app.toggle_autosave();
|
||||
} else {
|
||||
app.save_as_dialog();
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
qat_button(ui, app, "save", "Save", "file.save", true);
|
||||
let can_undo = app.session.can_undo();
|
||||
let can_redo = app.session.can_redo();
|
||||
qat_button(ui, app, "undo", &format!("Undo {}", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo);
|
||||
qat_button(ui, app, "redo", "Redo", "edit.redo", can_redo);
|
||||
qat_button(ui, app, "more", "Customize Quick Access Toolbar", "ui.dialog", true);
|
||||
qat_end = ui.min_rect().max.x;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 12.0, t.accent);
|
||||
let initials: String = app.session.author.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE);
|
||||
resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Search ("Tell me").
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click());
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "Search commands and help", regular(12.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "commands"}));
|
||||
}
|
||||
right_start = ui.min_rect().min.x;
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
11.0,
|
||||
if resp.hovered() { egui::Color32::from_rgb(0x47, 0x52, 0xC4) } else { egui::Color32::from_rgb(0x58, 0x65, 0xF2) },
|
||||
);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)),
|
||||
"discord",
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, "Discord", medium(11.5), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Join the ArtCraft community on Discord").clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Search ("Tell me").
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click());
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "Search commands and help", regular(12.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "commands"}));
|
||||
}
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
});
|
||||
// Centred title.
|
||||
let title = format!(
|
||||
"{} — {}",
|
||||
app.title_stem(),
|
||||
if app.session.dirty {
|
||||
"Edited"
|
||||
} else if app.session.path.is_some() {
|
||||
"Saved"
|
||||
} else {
|
||||
"Not saved"
|
||||
}
|
||||
);
|
||||
let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text));
|
||||
let w = g.size().x;
|
||||
let cx = full.center().x;
|
||||
if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 {
|
||||
ui.painter().galley(pos2(cx - w / 2.0, full.center().y - g.size().y / 2.0), g, t.text);
|
||||
}
|
||||
});
|
||||
// Centred title.
|
||||
let title = format!("{} — {}", app.title_stem(), if app.session.dirty { "Edited" } else if app.session.path.is_some() { "Saved" } else { "Not saved" });
|
||||
let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text));
|
||||
let w = g.size().x;
|
||||
let cx = full.center().x;
|
||||
if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 {
|
||||
ui.painter().galley(pos2(cx - w / 2.0, full.center().y - g.size().y / 2.0), g, t.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1);
|
||||
let st = |ui: &mut Ui, s: &str| ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click()));
|
||||
if st(ui, &format!("Page {page} of {}", l.pages.len())).on_hover_text("Go To (⌘⌥G)").clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
let wtxt = if app.session.sel.is_collapsed() {
|
||||
format!("{words} words")
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
format!("{sw} of {words} words")
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body {
|
||||
st(ui, "Editing header/footer");
|
||||
}
|
||||
st(ui, "English (United States)");
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, "Track Changes: On");
|
||||
}
|
||||
if let Some((msg, at)) = &app.status_msg {
|
||||
if crate::now_ms() - at < 6000.0 {
|
||||
egui::Panel::bottom("status")
|
||||
.exact_size(26.0)
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.fill(t.status_bar)
|
||||
.inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })
|
||||
.stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1);
|
||||
let st = |ui: &mut Ui, s: &str| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click()))
|
||||
};
|
||||
if st(ui, &format!("Page {page} of {}", l.pages.len())).on_hover_text("Go To (⌘⌥G)").clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
let wtxt = if app.session.sel.is_collapsed() {
|
||||
format!("{words} words")
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
format!("{sw} of {words} words")
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body {
|
||||
st(ui, "Editing header/footer");
|
||||
}
|
||||
st(ui, "English (United States)");
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, "Track Changes: On");
|
||||
}
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
{
|
||||
ui.label(egui::RichText::new(msg).font(regular(11.5)).color(t.accent_text));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round();
|
||||
if ui.add(egui::Label::new(egui::RichText::new(format!("{z}%")).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "zoom"}));
|
||||
}
|
||||
if small_icon(ui, "plus", "Zoom In") {
|
||||
let _ = app.run("view.zoomIn", json!({}));
|
||||
}
|
||||
// Zoom slider (10%–500%, 100% in the middle).
|
||||
let mut v = app.canvas.scale / crate::canvas::PX_PER_PT;
|
||||
let slider = egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
if ui.add(slider).changed() {
|
||||
let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if small_icon(ui, "minus", "Zoom Out") {
|
||||
let _ = app.run("view.zoomOut", json!({}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, on) in [
|
||||
("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round();
|
||||
if ui
|
||||
.add(egui::Label::new(egui::RichText::new(format!("{z}%")).font(regular(11.5)).color(t.text_dim)).sense(Sense::click()))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "zoom"}));
|
||||
}
|
||||
if small_icon(ui, "plus", "Zoom In") {
|
||||
let _ = app.run("view.zoomIn", json!({}));
|
||||
}
|
||||
// Zoom slider (10%–500%, 100% in the middle).
|
||||
let mut v = app.canvas.scale / crate::canvas::PX_PER_PT;
|
||||
let slider = egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
if ui.add(slider).changed() {
|
||||
let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if small_icon(ui, "minus", "Zoom Out") {
|
||||
let _ = app.run("view.zoomOut", json!({}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, on) in [
|
||||
("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
if resp.on_hover_text(id.trim_start_matches("view.")).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(56.0, 20.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
if resp.on_hover_text(id.trim_start_matches("view.")).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, "Focus", regular(11.5), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(56.0, 20.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, "Focus", regular(11.5), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn small_icon(ui: &mut Ui, icon: &str, tip: &str) -> bool {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,13 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
|||
fn mods(p: &Value) -> egui::Modifiers {
|
||||
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
let cmd = b("cmd") || b("command") || b("ctrl");
|
||||
egui::Modifiers { alt: b("alt"), ctrl: cmd && !cfg!(target_os = "macos"), shift: b("shift"), mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd }
|
||||
egui::Modifiers {
|
||||
alt: b("alt"),
|
||||
ctrl: cmd && !cfg!(target_os = "macos"),
|
||||
shift: b("shift"),
|
||||
mac_cmd: cmd && cfg!(target_os = "macos"),
|
||||
command: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, count: u64, m: egui::Modifiers) {
|
||||
|
|
@ -149,12 +155,22 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
|
|||
let m = mods(p);
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 100);
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(x, y), button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(x, y),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: m,
|
||||
});
|
||||
for i in 1..=steps {
|
||||
let k = i as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * k, y + (ty - y) * k)));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(tx, ty), button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(tx, ty),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: m,
|
||||
});
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.key" => {
|
||||
|
|
|
|||
|
|
@ -12,20 +12,92 @@ use crate::theme::{Tokens, semibold};
|
|||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "dialog", rename_all = "camelCase")]
|
||||
pub enum Dialog {
|
||||
Font { font: String, size: String, bold: bool, italic: bool, underline: bool, strike: bool, sup: bool, sub: bool, small_caps: bool, caps: bool, hidden: bool, color: String, spacing: f32 },
|
||||
Paragraph { align: String, left: f32, right: f32, first: f32, before: f32, after: f32, line: f32, keep_next: bool, keep_lines: bool, page_break: bool, widow: bool },
|
||||
Find { query: String, replace: String, match_case: bool, whole_word: bool, regex: bool, replace_mode: bool, message: String },
|
||||
Goto { page: String },
|
||||
InsertTable { rows: u32, cols: u32 },
|
||||
PageSetup { top: f32, bottom: f32, left: f32, right: f32, landscape: bool },
|
||||
Link { url: String, text: String },
|
||||
Bookmark { name: String },
|
||||
WordCount { stats: Value },
|
||||
Zoom { percent: f32 },
|
||||
Watermark { text: String, diagonal: bool },
|
||||
NewStyle { name: String, based_on: String },
|
||||
ModifyStyle { id: String, name: String, font: String, size: f32, bold: bool, italic: bool, color: String, before: f32, after: f32 },
|
||||
Commands { query: String },
|
||||
Font {
|
||||
font: String,
|
||||
size: String,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
sup: bool,
|
||||
sub: bool,
|
||||
small_caps: bool,
|
||||
caps: bool,
|
||||
hidden: bool,
|
||||
color: String,
|
||||
spacing: f32,
|
||||
},
|
||||
Paragraph {
|
||||
align: String,
|
||||
left: f32,
|
||||
right: f32,
|
||||
first: f32,
|
||||
before: f32,
|
||||
after: f32,
|
||||
line: f32,
|
||||
keep_next: bool,
|
||||
keep_lines: bool,
|
||||
page_break: bool,
|
||||
widow: bool,
|
||||
},
|
||||
Find {
|
||||
query: String,
|
||||
replace: String,
|
||||
match_case: bool,
|
||||
whole_word: bool,
|
||||
regex: bool,
|
||||
replace_mode: bool,
|
||||
message: String,
|
||||
},
|
||||
Goto {
|
||||
page: String,
|
||||
},
|
||||
InsertTable {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
},
|
||||
PageSetup {
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
left: f32,
|
||||
right: f32,
|
||||
landscape: bool,
|
||||
},
|
||||
Link {
|
||||
url: String,
|
||||
text: String,
|
||||
},
|
||||
Bookmark {
|
||||
name: String,
|
||||
},
|
||||
WordCount {
|
||||
stats: Value,
|
||||
},
|
||||
Zoom {
|
||||
percent: f32,
|
||||
},
|
||||
Watermark {
|
||||
text: String,
|
||||
diagonal: bool,
|
||||
},
|
||||
NewStyle {
|
||||
name: String,
|
||||
based_on: String,
|
||||
},
|
||||
ModifyStyle {
|
||||
id: String,
|
||||
name: String,
|
||||
font: String,
|
||||
size: f32,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
color: String,
|
||||
before: f32,
|
||||
after: f32,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
},
|
||||
About,
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +177,13 @@ impl Dialog {
|
|||
"insertTable" => Dialog::InsertTable { rows: 2, cols: 5 },
|
||||
"pageSetup" => {
|
||||
let sp = wordcraft_engine::cmd::page::sect(&app.session);
|
||||
Dialog::PageSetup { top: sp.margin_top / 72.0, bottom: sp.margin_bottom / 72.0, left: sp.margin_left / 72.0, right: sp.margin_right / 72.0, landscape: sp.landscape }
|
||||
Dialog::PageSetup {
|
||||
top: sp.margin_top / 72.0,
|
||||
bottom: sp.margin_bottom / 72.0,
|
||||
left: sp.margin_left / 72.0,
|
||||
right: sp.margin_right / 72.0,
|
||||
landscape: sp.landscape,
|
||||
}
|
||||
}
|
||||
"link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() },
|
||||
"bookmark" => Dialog::Bookmark { name: String::new() },
|
||||
|
|
@ -121,7 +199,13 @@ impl Dialog {
|
|||
|
||||
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(if st.kind == wordcraft_doc::StyleKind::Paragraph { Some(id) } else { None }, &wordcraft_doc::CharProps { style: if st.kind == wordcraft_doc::StyleKind::Character { Some(id.into()) } else { None }, ..Default::default() });
|
||||
let rc = app.session.doc.styles.resolve_char(
|
||||
if st.kind == wordcraft_doc::StyleKind::Paragraph { Some(id) } else { None },
|
||||
&wordcraft_doc::CharProps {
|
||||
style: if st.kind == wordcraft_doc::StyleKind::Character { Some(id.into()) } else { None },
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
|
||||
Some(Dialog::ModifyStyle {
|
||||
id: id.into(),
|
||||
|
|
@ -153,7 +237,13 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
|||
for c in 1..=10 {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
let on = r <= hover.0 && c <= hover.1;
|
||||
ui.painter().rect(rect, 1.0, if on { t.checked } else { t.input }, egui::Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
1.0,
|
||||
if on { t.checked } else { t.input },
|
||||
egui::Stroke::new(1.0, if on { t.accent } else { t.border_strong }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
if resp.hovered() {
|
||||
new_hover = (r, c);
|
||||
}
|
||||
|
|
@ -193,10 +283,13 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
|||
Dialog::Commands { .. } => "Search Commands",
|
||||
Dialog::About => "About WordCraft",
|
||||
};
|
||||
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)).open(&mut open).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
},
|
||||
);
|
||||
if open && !close {
|
||||
app.dialog = Some(d);
|
||||
} else {
|
||||
|
|
@ -315,11 +408,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("para.align", json!({"value": align}));
|
||||
let _ = app.run("para.set", json!({"props": {
|
||||
"indentLeft": *left * 72.0, "indentRight": *right * 72.0, "indentFirst": *first * 72.0,
|
||||
"spaceBefore": *before, "spaceAfter": *after, "lineSpacing": {"rule": "multiple", "value": *line},
|
||||
"keepNext": *keep_next, "keepLines": *keep_lines, "pageBreakBefore": *page_break, "widowControl": *widow,
|
||||
}}));
|
||||
let _ = app.run(
|
||||
"para.set",
|
||||
json!({"props": {
|
||||
"indentLeft": *left * 72.0, "indentRight": *right * 72.0, "indentFirst": *first * 72.0,
|
||||
"spaceBefore": *before, "spaceAfter": *after, "lineSpacing": {"rule": "multiple", "value": *line},
|
||||
"keepNext": *keep_next, "keepLines": *keep_lines, "pageBreakBefore": *page_break, "widowControl": *widow,
|
||||
}}),
|
||||
);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
|
@ -425,7 +521,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }}));
|
||||
let _ = app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0}));
|
||||
let _ =
|
||||
app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
|
@ -461,7 +558,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::WordCount { stats } => {
|
||||
egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| {
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Characters (no spaces)", "characters"), ("Characters (with spaces)", "charactersWithSpaces"), ("Paragraphs", "paragraphs"), ("Lines", "lines")] {
|
||||
for (l, k) in [
|
||||
("Pages", "pages"),
|
||||
("Words", "words"),
|
||||
("Characters (no spaces)", "characters"),
|
||||
("Characters (with spaces)", "charactersWithSpaces"),
|
||||
("Paragraphs", "paragraphs"),
|
||||
("Lines", "lines"),
|
||||
] {
|
||||
ui.label(l);
|
||||
ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default());
|
||||
ui.end_row();
|
||||
|
|
@ -561,7 +665,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
|
||||
chr["color"] = json!({"Rgb": [c.0, c.1, c.2]});
|
||||
}
|
||||
let _ = app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
|
||||
let _ =
|
||||
app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
|
@ -570,7 +675,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
r.request_focus();
|
||||
let q = query.to_lowercase();
|
||||
let reg = app.session.registry.clone();
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg.all().iter().filter(|c| !q.is_empty() && (c.label.to_lowercase().contains(&q) || c.id.to_lowercase().contains(&q) || c.location.to_lowercase().contains(&q))).collect();
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
!q.is_empty()
|
||||
&& (c.label.to_lowercase().contains(&q) || c.id.to_lowercase().contains(&q) || c.location.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect();
|
||||
hits.truncate(14);
|
||||
let mut close = false;
|
||||
for c in hits {
|
||||
|
|
|
|||
|
|
@ -188,9 +188,18 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
pen.line_c(&[(11.5, 14.5), (14.0, 17.0), (16.5, 14.5)], a);
|
||||
}
|
||||
"pilcrow" => pen.text(10.0, 10.5, 15.0, "¶", c, false),
|
||||
"alignLeft" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 3.0, 12.0, &[7.0, 13.0]),
|
||||
"alignCenter" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 6.0, 14.0, &[7.0, 13.0]),
|
||||
"alignRight" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 8.0, 17.0, &[7.0, 13.0]),
|
||||
"alignLeft" => {
|
||||
pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]);
|
||||
().then_lines(&pen, 3.0, 12.0, &[7.0, 13.0])
|
||||
}
|
||||
"alignCenter" => {
|
||||
pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]);
|
||||
().then_lines(&pen, 6.0, 14.0, &[7.0, 13.0])
|
||||
}
|
||||
"alignRight" => {
|
||||
pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]);
|
||||
().then_lines(&pen, 8.0, 17.0, &[7.0, 13.0])
|
||||
}
|
||||
"justify" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]),
|
||||
"lineSpacing" => {
|
||||
pen.lines(9.0, 17.0, &[5.0, 10.0, 15.0]);
|
||||
|
|
@ -322,9 +331,26 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
pen.fill(&[(14.0, 10.0), (18.0, 6.5), (18.0, 13.5)], a);
|
||||
}
|
||||
"link" => {
|
||||
pen.closed(&[(3.0, 10.0), (7.0, 6.0), (9.5, 6.0), (9.5, 8.0), (7.5, 8.0), (5.0, 10.5), (7.5, 13.0), (9.5, 13.0), (9.5, 15.0), (7.0, 15.0)], c);
|
||||
pen.closed(
|
||||
&[(3.0, 10.0), (7.0, 6.0), (9.5, 6.0), (9.5, 8.0), (7.5, 8.0), (5.0, 10.5), (7.5, 13.0), (9.5, 13.0), (9.5, 15.0), (7.0, 15.0)],
|
||||
c,
|
||||
);
|
||||
pen.line_c(&[(7.5, 10.5), (12.5, 10.5)], a);
|
||||
pen.closed(&[(17.0, 10.5), (13.0, 14.5), (10.5, 14.5), (10.5, 12.5), (12.5, 12.5), (15.0, 10.0), (12.5, 7.5), (10.5, 7.5), (10.5, 5.5), (13.0, 5.5)], c);
|
||||
pen.closed(
|
||||
&[
|
||||
(17.0, 10.5),
|
||||
(13.0, 14.5),
|
||||
(10.5, 14.5),
|
||||
(10.5, 12.5),
|
||||
(12.5, 12.5),
|
||||
(15.0, 10.0),
|
||||
(12.5, 7.5),
|
||||
(10.5, 7.5),
|
||||
(10.5, 5.5),
|
||||
(13.0, 5.5),
|
||||
],
|
||||
c,
|
||||
);
|
||||
}
|
||||
"bookmark" => {
|
||||
pen.closed(&[(5.5, 2.5), (14.5, 2.5), (14.5, 17.5), (10.0, 13.5), (5.5, 17.5)], c);
|
||||
|
|
@ -625,7 +651,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
pen.rect(2.5, 5.0, 17.5, 15.0, c);
|
||||
}
|
||||
"next" | "previous" | "first" | "last" => {
|
||||
let pts: &[(f32, f32)] = if name == "next" || name == "last" { &[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)] } else { &[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)] };
|
||||
let pts: &[(f32, f32)] =
|
||||
if name == "next" || name == "last" { &[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)] } else { &[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)] };
|
||||
pen.line_c(pts, a);
|
||||
}
|
||||
"finish" => {
|
||||
|
|
@ -664,7 +691,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
}
|
||||
"prevComment" | "nextComment" => {
|
||||
pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c);
|
||||
let pts: &[(f32, f32)] = if name == "nextComment" { &[(8.0, 6.0), (12.0, 8.5), (8.0, 11.0)] } else { &[(12.0, 6.0), (8.0, 8.5), (12.0, 11.0)] };
|
||||
let pts: &[(f32, f32)] =
|
||||
if name == "nextComment" { &[(8.0, 6.0), (12.0, 8.5), (8.0, 11.0)] } else { &[(12.0, 6.0), (8.0, 8.5), (12.0, 11.0)] };
|
||||
pen.line_c(pts, a);
|
||||
}
|
||||
"showComments" => {
|
||||
|
|
@ -697,7 +725,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
}
|
||||
"prevChange" | "nextChange" => {
|
||||
pen.page(4.0, 2.0, 16.0, 18.0);
|
||||
let pts: &[(f32, f32)] = if name == "nextChange" { &[(8.5, 7.0), (11.5, 10.0), (8.5, 13.0)] } else { &[(11.5, 7.0), (8.5, 10.0), (11.5, 13.0)] };
|
||||
let pts: &[(f32, f32)] =
|
||||
if name == "nextChange" { &[(8.5, 7.0), (11.5, 10.0), (8.5, 13.0)] } else { &[(11.5, 7.0), (8.5, 10.0), (11.5, 13.0)] };
|
||||
pen.line_c(pts, a);
|
||||
}
|
||||
"compare" | "combine" => {
|
||||
|
|
@ -896,10 +925,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
"undo" => {
|
||||
pen.line(&[(5.0, 8.0), (13.0, 8.0)]);
|
||||
pen.p.add(Shape::line(
|
||||
(0..=12).map(|i| {
|
||||
let t = -std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0;
|
||||
pen.pt(13.0 + 4.5 * t.cos(), 12.5 + 4.5 * t.sin())
|
||||
}).collect(),
|
||||
(0..=12)
|
||||
.map(|i| {
|
||||
let t = -std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0;
|
||||
pen.pt(13.0 + 4.5 * t.cos(), 12.5 + 4.5 * t.sin())
|
||||
})
|
||||
.collect(),
|
||||
pen.stroke(c),
|
||||
));
|
||||
pen.line(&[(13.0, 17.0), (8.0, 17.0)]);
|
||||
|
|
@ -908,10 +939,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
"redo" => {
|
||||
pen.line(&[(15.0, 8.0), (7.0, 8.0)]);
|
||||
pen.p.add(Shape::line(
|
||||
(0..=12).map(|i| {
|
||||
let t = std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0;
|
||||
pen.pt(7.0 + 4.5 * t.cos(), 12.5 - 4.5 * t.sin())
|
||||
}).collect(),
|
||||
(0..=12)
|
||||
.map(|i| {
|
||||
let t = std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0;
|
||||
pen.pt(7.0 + 4.5 * t.cos(), 12.5 - 4.5 * t.sin())
|
||||
})
|
||||
.collect(),
|
||||
pen.stroke(c),
|
||||
));
|
||||
pen.line(&[(7.0, 17.0), (12.0, 17.0)]);
|
||||
|
|
@ -929,10 +962,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
"user" => {
|
||||
pen.fcircle(10.0, 7.0, 3.5, a);
|
||||
pen.p.add(Shape::convex_polygon(
|
||||
(0..=16).map(|i| {
|
||||
let t = std::f32::consts::PI * i as f32 / 16.0;
|
||||
pen.pt(10.0 - 6.5 * t.cos(), 18.0 - 6.0 * t.sin())
|
||||
}).collect(),
|
||||
(0..=16)
|
||||
.map(|i| {
|
||||
let t = std::f32::consts::PI * i as f32 / 16.0;
|
||||
pen.pt(10.0 - 6.5 * t.cos(), 18.0 - 6.0 * t.sin())
|
||||
})
|
||||
.collect(),
|
||||
a,
|
||||
Stroke::NONE,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -161,12 +161,12 @@ impl WordApp {
|
|||
for req in std::mem::take(&mut self.session.ui_requests) {
|
||||
self.handle_request(&req);
|
||||
}
|
||||
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string()) {
|
||||
if self.ui.recent.first() != Some(&p) {
|
||||
self.ui.recent.retain(|r| *r != p);
|
||||
self.ui.recent.insert(0, p);
|
||||
self.ui.recent.truncate(12);
|
||||
}
|
||||
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string())
|
||||
&& self.ui.recent.first() != Some(&p)
|
||||
{
|
||||
self.ui.recent.retain(|r| *r != p);
|
||||
self.ui.recent.insert(0, p);
|
||||
self.ui.recent.truncate(12);
|
||||
}
|
||||
if !self.session.status.is_empty() {
|
||||
let s = std::mem::take(&mut self.session.status);
|
||||
|
|
@ -310,7 +310,12 @@ impl WordApp {
|
|||
self.drain_inbox();
|
||||
// AutoSave: write a saved document a couple of seconds after the last change.
|
||||
let now = now_ms();
|
||||
if self.autosave && self.session.dirty && self.session.path.is_some() && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 {
|
||||
if self.autosave
|
||||
&& self.session.dirty
|
||||
&& self.session.path.is_some()
|
||||
&& now - self.last_autosave > 2500.0
|
||||
&& now - self.canvas.caret_visible_since > 1500.0
|
||||
{
|
||||
self.last_autosave = now;
|
||||
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if ext == "docx" || ext == "json" || ext == "odt" || ext == "rtf" {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,25 @@ use crate::theme::{Tokens, regular, semibold};
|
|||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if app.session.view.nav_pane {
|
||||
egui::Panel::left("nav_pane").default_size(260.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| nav(app, ui));
|
||||
egui::Panel::left("nav_pane")
|
||||
.default_size(260.0)
|
||||
.resizable(true)
|
||||
.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.styles_pane {
|
||||
egui::Panel::right("styles_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| styles(app, ui));
|
||||
egui::Panel::right("styles_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| styles(app, ui));
|
||||
}
|
||||
if app.session.view.comments_pane {
|
||||
egui::Panel::right("comments_pane").default_size(290.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| comments(app, ui));
|
||||
egui::Panel::right("comments_pane")
|
||||
.default_size(290.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| comments(app, ui));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +158,15 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
ui.separator();
|
||||
let mut list: Vec<(String, String, bool)> = app.session.doc.styles.styles.iter().filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table).map(|s| (s.id.clone(), s.name.clone(), s.kind == wordcraft_doc::StyleKind::Character)).collect();
|
||||
let mut list: Vec<(String, String, bool)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table)
|
||||
.map(|s| (s.id.clone(), s.name.clone(), s.kind == wordcraft_doc::StyleKind::Character))
|
||||
.collect();
|
||||
list.sort_by_key(|s| s.1.to_lowercase());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (id, name, is_char) in list {
|
||||
|
|
@ -215,7 +235,8 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
|
|||
ui.data_mut(|d| d.insert_temp(key, text.clone()));
|
||||
}
|
||||
if r.lost_focus() {
|
||||
let blocks = text.split('\n').map(|l| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(l, Default::default()))).collect();
|
||||
let blocks =
|
||||
text.split('\n').map(|l| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(l, Default::default()))).collect();
|
||||
let _ = app.session.doc.set_story(StoryRef::Part(part), blocks);
|
||||
app.session.touch();
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ impl Previews {
|
|||
return None;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let img = snippet(&Document::new(), Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }), 240.0, 20.0, ppp, 2.0)?;
|
||||
let img = snippet(
|
||||
&Document::new(),
|
||||
Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }),
|
||||
240.0,
|
||||
20.0,
|
||||
ppp,
|
||||
2.0,
|
||||
)?;
|
||||
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
Some(h)
|
||||
|
|
@ -71,7 +78,12 @@ impl Previews {
|
|||
match tex {
|
||||
Some(h) => {
|
||||
let sz = h.size_vec2() / ui.ctx().pixels_per_point();
|
||||
ui.painter().image(h.id(), Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
ui.painter().image(
|
||||
h.id(),
|
||||
Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz),
|
||||
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text);
|
||||
|
|
@ -122,7 +134,15 @@ pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margi
|
|||
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None)
|
||||
}
|
||||
|
||||
fn snippet_blocks(base: &Document, blocks: Vec<Block>, w: f32, h: f32, ppp: f32, margin: f32, paper: Option<wordcraft_doc::Rgb>) -> Option<egui::ColorImage> {
|
||||
fn snippet_blocks(
|
||||
base: &Document,
|
||||
blocks: Vec<Block>,
|
||||
w: f32,
|
||||
h: f32,
|
||||
ppp: f32,
|
||||
margin: f32,
|
||||
paper: Option<wordcraft_doc::Rgb>,
|
||||
) -> Option<egui::ColorImage> {
|
||||
let mut d = Document::new();
|
||||
d.styles = base.styles.clone();
|
||||
d.numbering = base.numbering.clone();
|
||||
|
|
|
|||
|
|
@ -16,69 +16,80 @@ fn in_table(app: &WordApp) -> bool {
|
|||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Tab strip.
|
||||
egui::Panel::top("tabs").exact_size(30.0).frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 })).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
let mut tabs: Vec<&str> = TABS.to_vec();
|
||||
if in_table(app) {
|
||||
tabs.push("Table Design");
|
||||
tabs.push("Table Layout");
|
||||
}
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ");
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(tab.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
egui::Panel::top("tabs")
|
||||
.exact_size(30.0)
|
||||
.frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
let mut tabs: Vec<&str> = TABS.to_vec();
|
||||
if in_table(app) {
|
||||
tabs.push("Table Design");
|
||||
tabs.push("Table Layout");
|
||||
}
|
||||
let color = if contextual { t.accent_text } else if active { t.accent_text } else { t.text };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { semibold(12.5) } else { medium(12.5) }, color);
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
}
|
||||
if resp.double_clicked() && tab != "File" {
|
||||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Share, Editing mode, Comments.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(74.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "Share", medium(12.0), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Export a copy to share (PDF, Word document)").clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
ui.menu_button(egui::RichText::new(if track { "✎ Reviewing ▾" } else { "✎ Editing ▾" }).font(regular(12.0)), |ui| {
|
||||
if ui.selectable_label(!track, "Editing — edit the document directly").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
ui.close();
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ");
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(tab.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
if ui.selectable_label(track, "Reviewing — edits become suggestions").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||||
ui.close();
|
||||
let color = if contextual || active { t.accent_text } else { t.text };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { semibold(12.5) } else { medium(12.5) }, color);
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
}
|
||||
if resp.double_clicked() && tab != "File" {
|
||||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Share, Editing mode, Comments.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(74.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)),
|
||||
"share",
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "Share", medium(12.0), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Export a copy to share (PDF, Word document)").clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
ui.menu_button(egui::RichText::new(if track { "✎ Reviewing ▾" } else { "✎ Editing ▾" }).font(regular(12.0)), |ui| {
|
||||
if ui.selectable_label(!track, "Editing — edit the document directly").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.selectable_label(track, "Reviewing — edits become suggestions").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if ui.button(egui::RichText::new("💬 Comments").font(regular(12.0))).clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if ui.button(egui::RichText::new("💬 Comments").font(regular(12.0))).clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if app.ui.ribbon_collapsed {
|
||||
return;
|
||||
}
|
||||
egui::Panel::top("ribbon")
|
||||
.exact_size(CONTENT_H + LABEL_H + 10.0)
|
||||
.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)))
|
||||
.frame(
|
||||
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
|
|
@ -148,8 +159,13 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
if let Some(f) = combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) {
|
||||
let _ = app.run("format.font", json!({"name": f}));
|
||||
}
|
||||
let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") }).unwrap_or_default();
|
||||
let sizes: Vec<String> = wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
|
||||
let size = st
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") })
|
||||
.unwrap_or_default();
|
||||
let sizes: Vec<String> =
|
||||
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
|
||||
if let Some(v) = combo(ui, "size", 52.0, &size, &sizes, None)
|
||||
&& let Ok(x) = v.trim().parse::<f64>()
|
||||
{
|
||||
|
|
@ -172,7 +188,16 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold"));
|
||||
small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic"));
|
||||
split(ui, app, "underline", "Underline", "format.underline", json!({}), flag("underline"), None, |ui, app| {
|
||||
for (l, s) in [("Single", "single"), ("Double", "double"), ("Thick", "thick"), ("Dotted", "dotted"), ("Dashed", "dash"), ("Dot-dash", "dotDash"), ("Wave", "wave"), ("Words only", "words")] {
|
||||
for (l, s) in [
|
||||
("Single", "single"),
|
||||
("Double", "double"),
|
||||
("Thick", "thick"),
|
||||
("Dotted", "dotted"),
|
||||
("Dashed", "dash"),
|
||||
("Dot-dash", "dotDash"),
|
||||
("Wave", "wave"),
|
||||
("Words only", "words"),
|
||||
] {
|
||||
mi(ui, app, l, "format.underline", json!({"style": s}));
|
||||
}
|
||||
});
|
||||
|
|
@ -239,7 +264,14 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
mi(ui, app, "None", "para.bullets", json!({"off": true}));
|
||||
});
|
||||
split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), false, None, |ui, app| {
|
||||
for (l, k) in [("1. 2. 3.", "numbered"), ("1) 2) 3)", "numberedParen"), ("I. II. III.", "outline"), ("A. B. C.", "upperLetter"), ("a) b) c)", "lowerLetter"), ("i. ii. iii.", "lowerRoman")] {
|
||||
for (l, k) in [
|
||||
("1. 2. 3.", "numbered"),
|
||||
("1) 2) 3)", "numberedParen"),
|
||||
("I. II. III.", "outline"),
|
||||
("A. B. C.", "upperLetter"),
|
||||
("a) b) c)", "lowerLetter"),
|
||||
("i. ii. iii.", "lowerRoman"),
|
||||
] {
|
||||
mi(ui, app, l, "para.numbering", json!({"kind": k}));
|
||||
}
|
||||
ui.separator();
|
||||
|
|
@ -289,7 +321,16 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| {
|
||||
for (l, k) in [("Bottom Border", "bottom"), ("Top Border", "top"), ("Left Border", "left"), ("Right Border", "right"), ("No Border", "none"), ("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside")] {
|
||||
for (l, k) in [
|
||||
("Bottom Border", "bottom"),
|
||||
("Top Border", "top"),
|
||||
("Left Border", "left"),
|
||||
("Right Border", "right"),
|
||||
("No Border", "none"),
|
||||
("All Borders", "all"),
|
||||
("Outside Borders", "outside"),
|
||||
("Inside Borders", "inside"),
|
||||
] {
|
||||
mi(ui, app, l, "para.borders", json!({"kind": k}));
|
||||
}
|
||||
ui.separator();
|
||||
|
|
@ -303,7 +344,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
group(ui, "Editing", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
if small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked() {}
|
||||
small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked();
|
||||
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!({}));
|
||||
|
|
@ -346,7 +387,17 @@ fn insert(app: &mut WordApp, ui: &mut Ui) {
|
|||
group(ui, "Illustrations", None, app, |ui, app| {
|
||||
big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false);
|
||||
menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, |ui, app| {
|
||||
for (l, k) in [("Rectangle", "rectangle"), ("Rounded Rectangle", "roundedRectangle"), ("Oval", "ellipse"), ("Triangle", "triangle"), ("Diamond", "diamond"), ("Line", "line"), ("Arrow", "arrow"), ("Star", "star"), ("Heart", "heart")] {
|
||||
for (l, k) in [
|
||||
("Rectangle", "rectangle"),
|
||||
("Rounded Rectangle", "roundedRectangle"),
|
||||
("Oval", "ellipse"),
|
||||
("Triangle", "triangle"),
|
||||
("Diamond", "diamond"),
|
||||
("Line", "line"),
|
||||
("Arrow", "arrow"),
|
||||
("Star", "star"),
|
||||
("Heart", "heart"),
|
||||
] {
|
||||
mi(ui, app, l, "insert.shape", json!({"kind": k}));
|
||||
}
|
||||
});
|
||||
|
|
@ -408,7 +459,13 @@ fn insert(app: &mut WordApp, ui: &mut Ui) {
|
|||
big(ui, app, "equation", "Equation", "insert.equation", json!({}), false);
|
||||
menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, |ui, app| {
|
||||
egui::Grid::new("syms").show(ui, |ui| {
|
||||
for (i, c) in ["©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓", "★", "♥", "—", "…"].iter().enumerate() {
|
||||
for (i, c) in [
|
||||
"©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓",
|
||||
"★", "♥", "—", "…",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
if ui.button(egui::RichText::new(*c).size(16.0)).clicked() {
|
||||
let _ = app.run("insert.symbol", json!({"char": c}));
|
||||
ui.close();
|
||||
|
|
@ -467,7 +524,15 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, |ui, app| {
|
||||
for (l, v) in [("Default", "default"), ("No Paragraph Space", "none"), ("Compact", "compact"), ("Tight", "tight"), ("Open", "open"), ("Relaxed", "relaxed"), ("Double", "double")] {
|
||||
for (l, v) in [
|
||||
("Default", "default"),
|
||||
("No Paragraph Space", "none"),
|
||||
("Compact", "compact"),
|
||||
("Tight", "tight"),
|
||||
("Open", "open"),
|
||||
("Relaxed", "relaxed"),
|
||||
("Double", "double"),
|
||||
] {
|
||||
mi(ui, app, l, "design.paragraphSpacing", json!({"value": v}));
|
||||
}
|
||||
});
|
||||
|
|
@ -502,7 +567,14 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
|
|||
fn layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| {
|
||||
menu_button(ui, app, "margins", Some("Margins"), "Margins", true, |ui, app| {
|
||||
for (l, k) in [("Normal 1\" all", "normal"), ("Narrow 0.5\" all", "narrow"), ("Moderate", "moderate"), ("Wide", "wide"), ("Mirrored", "mirrored"), ("Office 2003 Default", "office2003")] {
|
||||
for (l, k) in [
|
||||
("Normal 1\" all", "normal"),
|
||||
("Narrow 0.5\" all", "narrow"),
|
||||
("Moderate", "moderate"),
|
||||
("Wide", "wide"),
|
||||
("Mirrored", "mirrored"),
|
||||
("Office 2003 Default", "office2003"),
|
||||
] {
|
||||
mi(ui, app, l, "layout.margins", json!({"preset": k}));
|
||||
}
|
||||
mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"}));
|
||||
|
|
@ -536,7 +608,9 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
|
|||
mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"}));
|
||||
});
|
||||
menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, |ui, app| {
|
||||
for (l, v) in [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] {
|
||||
for (l, v) in
|
||||
[("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")]
|
||||
{
|
||||
mi(ui, app, l, "layout.lineNumbers", json!({"value": v}));
|
||||
}
|
||||
});
|
||||
|
|
@ -644,7 +718,16 @@ fn references(app: &mut WordApp, ui: &mut Ui) {
|
|||
group(ui, "Captions", None, app, |ui, app| {
|
||||
big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "tableOfFigures", Some("Insert Table of Figures"), "Insert Table of Figures", "references.tableOfFigures", json!({}), false);
|
||||
small(
|
||||
ui,
|
||||
app,
|
||||
"tableOfFigures",
|
||||
Some("Insert Table of Figures"),
|
||||
"Insert Table of Figures",
|
||||
"references.tableOfFigures",
|
||||
json!({}),
|
||||
false,
|
||||
);
|
||||
small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false);
|
||||
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
|
||||
});
|
||||
|
|
@ -731,10 +814,18 @@ fn review(app: &mut WordApp, ui: &mut Ui) {
|
|||
let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false);
|
||||
let _ = r;
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "markup", Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), "Display for Review", false, |ui, app| {
|
||||
mi(ui, app, "All Markup", "review.markup", json!({"value": "all"}));
|
||||
mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"}));
|
||||
});
|
||||
menu_button(
|
||||
ui,
|
||||
app,
|
||||
"markup",
|
||||
Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }),
|
||||
"Display for Review",
|
||||
false,
|
||||
|ui, app| {
|
||||
mi(ui, app, "All Markup", "review.markup", json!({"value": "all"}));
|
||||
mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"}));
|
||||
},
|
||||
);
|
||||
small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false);
|
||||
});
|
||||
});
|
||||
|
|
@ -833,7 +924,14 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
group(ui, "Table Style Options", None, app, |ui, app| {
|
||||
let Some(l) = look else { return };
|
||||
egui::Grid::new("look").show(ui, |ui| {
|
||||
for (row, items) in [[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], [("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)]].iter().enumerate() {
|
||||
for (row, items) in [
|
||||
[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)],
|
||||
[("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)],
|
||||
[("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)],
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let _ = row;
|
||||
for (label, key, val) in items {
|
||||
let mut v = *val;
|
||||
|
|
@ -846,7 +944,15 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(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())).collect();
|
||||
let styles: Vec<(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()))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
|
|
@ -870,7 +976,14 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
group(ui, "Borders", None, app, |ui, app| {
|
||||
menu_button(ui, app, "borders", Some("Borders"), "Borders", true, |ui, app| {
|
||||
for (l, k) in [("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside"), ("No Border", "none"), ("Top Border", "top"), ("Bottom Border", "bottom")] {
|
||||
for (l, k) in [
|
||||
("All Borders", "all"),
|
||||
("Outside Borders", "outside"),
|
||||
("Inside Borders", "inside"),
|
||||
("No Border", "none"),
|
||||
("Top Border", "top"),
|
||||
("Bottom Border", "bottom"),
|
||||
] {
|
||||
mi(ui, app, l, "table.borders", json!({"kind": k}));
|
||||
}
|
||||
});
|
||||
|
|
@ -925,7 +1038,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
group(ui, "Alignment", None, app, |ui, app| {
|
||||
egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
|
||||
for row in [["topLeft", "topCenter", "topRight"], ["centerLeft", "center", "centerRight"], ["bottomLeft", "bottomCenter", "bottomRight"]] {
|
||||
for row in [["topLeft", "topCenter", "topRight"], ["centerLeft", "center", "centerRight"], ["bottomLeft", "bottomCenter", "bottomRight"]]
|
||||
{
|
||||
for v in row {
|
||||
small(ui, app, "cellAlign", None, v, "table.cellAlign", json!({"value": v}), false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
|
|||
/// A large ribbon button: 32 px icon over a (possibly two-line) label.
|
||||
pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley_w = label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
|
||||
let galley_w =
|
||||
label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
|
||||
let w = (galley_w + 12.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
|
|
@ -114,7 +115,17 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
|
|||
}
|
||||
|
||||
/// A split button: the left part runs `id`, the arrow opens a menu filled by `menu`.
|
||||
pub fn split(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, params: Value, checked: bool, swatch: Option<Color32>, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
pub fn split(
|
||||
ui: &mut Ui,
|
||||
app: &mut WordApp,
|
||||
icon: &str,
|
||||
tip: &str,
|
||||
id: &str,
|
||||
params: Value,
|
||||
checked: bool,
|
||||
swatch: Option<Color32>,
|
||||
menu: impl FnOnce(&mut Ui, &mut WordApp),
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
|
||||
|
|
@ -137,10 +148,22 @@ pub fn split(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, pa
|
|||
}
|
||||
|
||||
/// A button that only opens a menu (icon + label + ▾).
|
||||
pub fn menu_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, big_btn: bool, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
pub fn menu_button(
|
||||
ui: &mut Ui,
|
||||
app: &mut WordApp,
|
||||
icon: &str,
|
||||
label: Option<&str>,
|
||||
tip: &str,
|
||||
big_btn: bool,
|
||||
menu: impl FnOnce(&mut Ui, &mut WordApp),
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = if big_btn {
|
||||
let galley_w = label.unwrap_or("").split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
|
||||
let galley_w = label
|
||||
.unwrap_or("")
|
||||
.split('\n')
|
||||
.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x))
|
||||
.fold(0.0, f32::max);
|
||||
let w = (galley_w + 22.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
bg(ui, r, &resp, false, &t);
|
||||
|
|
@ -252,12 +275,24 @@ fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
|
|||
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
0.0,
|
||||
Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
resp.on_hover_text(format!("#{}", c.hex())).clicked()
|
||||
}
|
||||
|
||||
/// A ribbon-style text field with a dropdown list. Returns a chosen/typed value.
|
||||
pub fn combo(ui: &mut Ui, id: &str, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option<String> {
|
||||
pub fn combo(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
width: f32,
|
||||
current: &str,
|
||||
items: &[String],
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
) -> Option<String> {
|
||||
let mut out = None;
|
||||
let edit_id = ui.id().with(("combo_text", id));
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(edit_id)).unwrap_or_else(|| current.to_string());
|
||||
|
|
|
|||
|
|
@ -8,38 +8,8 @@ use std::path::Path;
|
|||
use std::process::Command;
|
||||
|
||||
const ASSET_EXT: &[&str] = &[
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"ico",
|
||||
"icns",
|
||||
"svg",
|
||||
"pdf",
|
||||
"ai",
|
||||
"eps",
|
||||
"psd",
|
||||
"icc",
|
||||
"icm",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"ase",
|
||||
"indd",
|
||||
"idml",
|
||||
"aco",
|
||||
"abr",
|
||||
"docx",
|
||||
"odt",
|
||||
"rtf",
|
||||
"mp4",
|
||||
"wav",
|
||||
"mp3",
|
||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico", "icns", "svg", "pdf", "ai", "eps", "psd", "icc", "icm", "ttf", "otf", "woff",
|
||||
"woff2", "ase", "indd", "idml", "aco", "abr", "docx", "odt", "rtf", "mp4", "wav", "mp3",
|
||||
];
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue