mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
M0: reconcile current main and classify double-space rewrites for egui 0.37
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
use super::sel_result;
|
||||
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
|
||||
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
|
||||
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| {
|
||||
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Keep custom styles and fonts.
|
||||
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
|
||||
fresh.styles.extend(customs);
|
||||
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
|
||||
s.doc.styles = fresh;
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
for mut st in fresh.styles {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => s.doc.styles.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
set_fonts(s, &h, &b);
|
||||
set_accent(s, accent);
|
||||
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
|
||||
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
|
||||
/// keep their own direct formatting.
|
||||
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
|
||||
// A field that is present but not a number is an error, not "absent".
|
||||
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
|
||||
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
|
||||
}
|
||||
if delta.is_some() == percent.is_some() {
|
||||
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
|
||||
}
|
||||
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
|
||||
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
|
||||
}
|
||||
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
|
||||
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
|
||||
let scale_chr = |c: &mut CharProps| {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = &mut s.doc.styles;
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
for style in &mut st.styles {
|
||||
scale_chr(&mut style.chr);
|
||||
if let Some(t) = style.table.as_mut() {
|
||||
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
|
||||
scale_chr(c);
|
||||
}
|
||||
for part in t.regions.values_mut() {
|
||||
scale_chr(&mut part.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "remove").unwrap_or(false) {
|
||||
s.doc.settings.watermark = None;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
|
||||
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
|
||||
.build(&c.pattern)
|
||||
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
|
||||
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
|
||||
let pieces = with_rep.then(|| {
|
||||
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
|
||||
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
|
||||
});
|
||||
let paths = s.doc.para_paths(story);
|
||||
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
|
||||
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
|
||||
@@ -345,15 +345,20 @@ pub enum Piece {
|
||||
Lit(String),
|
||||
/// `^&`: the text found.
|
||||
Found,
|
||||
/// `\n` with wildcards: group `n`.
|
||||
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
|
||||
Group(usize),
|
||||
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
|
||||
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
|
||||
Named(String),
|
||||
/// `^c`: the clipboard.
|
||||
Clip,
|
||||
}
|
||||
|
||||
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
|
||||
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
|
||||
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
|
||||
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
if o.literal || o.regex {
|
||||
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
|
||||
if o.literal && !o.regex {
|
||||
return vec![Piece::Lit(with.to_string())];
|
||||
}
|
||||
let chars: Vec<char> = with.chars().collect();
|
||||
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
i += 1;
|
||||
if o.regex && (c == '$' || c == '\\') {
|
||||
match regex_ref(&chars, i, c) {
|
||||
Some((Ref::Char(ch), used)) => {
|
||||
cur.push(ch);
|
||||
i += used;
|
||||
}
|
||||
Some((Ref::Group(pc), used)) => {
|
||||
flush(&mut cur, &mut out);
|
||||
out.extend(pc);
|
||||
i += used;
|
||||
}
|
||||
// Not a reference or an escape: the character as written.
|
||||
None => cur.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && o.wildcards {
|
||||
match chars.get(i) {
|
||||
Some(d) if d.is_ascii_digit() && *d != '0' => {
|
||||
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
}
|
||||
}
|
||||
if c == '^'
|
||||
&& !o.literal
|
||||
&& let Some(&n) = chars.get(i)
|
||||
{
|
||||
if n == '&' || n == 'c' {
|
||||
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What a `$` or `\` starts in regex Replace with text.
|
||||
enum Ref {
|
||||
/// An escape: `$$`, `\n`, `\t`, `\\`.
|
||||
Char(char),
|
||||
/// A group reference; `None` for a number too large to be a group (expands to nothing).
|
||||
Group(Option<Piece>),
|
||||
}
|
||||
|
||||
/// A group named by its number or name, as the `regex` crate reads it.
|
||||
fn group_ref(name: &str) -> Option<Piece> {
|
||||
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
|
||||
}
|
||||
|
||||
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
|
||||
/// many characters it uses after `c`, or `None` when `c` is a plain character.
|
||||
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
|
||||
let next = *chars.get(i)?;
|
||||
if c == '\\' {
|
||||
return match next {
|
||||
'n' => Some((Ref::Char(PARA_MARK), 1)),
|
||||
't' => Some((Ref::Char('\t'), 1)),
|
||||
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
|
||||
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
|
||||
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if next == '$' {
|
||||
return Some((Ref::Char('$'), 1));
|
||||
}
|
||||
let rest = chars.get(i..).unwrap_or_default();
|
||||
if next == '{' {
|
||||
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
|
||||
let close = rest.iter().position(|&ch| ch == '}')?;
|
||||
let name: String = rest.get(1..close)?.iter().collect();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some((Ref::Group(group_ref(&name)), close + 1));
|
||||
}
|
||||
// `$name`: the longest run of letters, digits and `_`.
|
||||
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let name: String = rest.get(..len)?.iter().collect();
|
||||
Some((Ref::Group(group_ref(&name)), len))
|
||||
}
|
||||
|
||||
/// Turn named group references into numbered ones with `index` (a name's group number);
|
||||
/// an unknown name is dropped, so it expands to nothing.
|
||||
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
|
||||
pieces
|
||||
.into_iter()
|
||||
.filter_map(|p| match p {
|
||||
Piece::Named(n) => index(&n).map(Piece::Group),
|
||||
p => Some(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Searched text as it is inserted again: marks become paragraph breaks.
|
||||
fn as_inserted(s: &str) -> String {
|
||||
s.replace(SECT_MARK, "\r")
|
||||
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
|
||||
Piece::Found => out.push_str(&as_inserted(found)),
|
||||
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
|
||||
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
|
||||
Piece::Named(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -97,13 +97,14 @@ fn typing_options(s: &mut Session, text: &str) {
|
||||
}
|
||||
|
||||
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
|
||||
/// single trigger character runs list, AutoCorrect and dash AutoFormat as a separate undo step
|
||||
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
|
||||
/// when they rewrite the paragraph.
|
||||
fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::req_str(v, "text")?;
|
||||
let raw = p::bool(v, "raw").unwrap_or(false);
|
||||
typing_options(s, text);
|
||||
let smart_quote = if raw { None } else { autoformat(s, text) };
|
||||
let was_collapsed = s.sel.is_collapsed();
|
||||
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
|
||||
let mut outcome = crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
@@ -130,9 +131,12 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
&& s.prefs.autocorrect.enabled
|
||||
&& s.prefs.autocorrect.dashes
|
||||
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
|
||||
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
|
||||
double_space_period(s)?;
|
||||
}
|
||||
// Compare the paragraph with the one exactly as typed. This is the actual effect of
|
||||
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
|
||||
// may have been left by an earlier step.
|
||||
// may have been left by an earlier step. It also covers the double-space period.
|
||||
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
|
||||
if listed || dashed || rewritten {
|
||||
outcome.content_changed = true;
|
||||
@@ -210,6 +214,27 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
|
||||
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
|
||||
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
|
||||
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
|
||||
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
|
||||
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
|
||||
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
|
||||
return Ok(());
|
||||
}
|
||||
// The period takes the formatting of the space it replaces.
|
||||
let at = word.len();
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
let props = p.props_at(at + 1).clone().without_symbol();
|
||||
p.delete(at, at + 1)?;
|
||||
p.insert_text(at, ".", &props)?;
|
||||
s.sel = Selection::caret(f);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
|
||||
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
|
||||
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// A second Space after a word, number or closing bracket or quote turns the first into a
|
||||
/// period (macOS's "Add period with double-space"); off by default.
|
||||
pub double_space_period: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
double_space_period: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("doubleSpacePeriod", &mut ac.double_space_period),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"doubleSpacePeriod": ac.double_space_period,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -462,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
|
||||
Reference in New Issue
Block a user