mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:19:54 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565032feb1 | ||
|
|
5c5c35899c |
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -1,10 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
Read [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent.
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
@@ -28,7 +80,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
|
||||
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
|
||||
|
||||
## Architecture (see `plan/architecture.md`)
|
||||
## Architecture
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
@@ -44,7 +96,6 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
@@ -85,3 +136,5 @@ Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/targe
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
|
||||
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
|
||||
|
||||
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
|
||||
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
|
||||
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
|
||||
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
|
||||
|
||||
## Architecture (see `plan/architecture.md`)
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
- Drive it with JSON lines on `127.0.0.1:7981`, each with the window's key from `~/.config/wordcraft/control-key.local-7981` (macOS: `~/Library/Application Support/WordCraft/`), e.g. `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"key":"…","method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md` (Keys).
|
||||
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
|
||||
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
|
||||
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
|
||||
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
|
||||
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
+1
-7
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
|
||||
@@ -316,33 +316,19 @@ impl InlineObject {
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
|
||||
}
|
||||
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
|
||||
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
let (sx, sy) = self.group_scale(w, h);
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
|
||||
/// members keep their size.
|
||||
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
(sx, sy)
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
|
||||
@@ -169,9 +169,6 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
if a == b {
|
||||
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
|
||||
}
|
||||
if canvas::selected_member(s).is_some() {
|
||||
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
|
||||
}
|
||||
controls::check_delete(s, &a, &b)?;
|
||||
let effect = if s.doc.settings.track_changes {
|
||||
if !track_delete_would_change(s, &a, &b) {
|
||||
@@ -189,9 +186,7 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
Ok(effect)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position. With a
|
||||
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
|
||||
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
delete_selection_effect(s).map(|e| e.at)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
|
||||
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
|
||||
//! Shape Fill and Shape Outline colour its background and border.
|
||||
//!
|
||||
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
|
||||
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
|
||||
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
|
||||
@@ -18,29 +14,6 @@ use super::objects::object_selection;
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
|
||||
/// in (it counts only while that selection stands).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CanvasMember {
|
||||
sel: Selection,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
|
||||
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
|
||||
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
|
||||
let (pos, canvas) = selected_canvas(s)?;
|
||||
let InlineObject::Group { children, .. } = canvas else { return None };
|
||||
let c = children.get(m.index).filter(|c| pickable(c))?;
|
||||
Some((pos, m.index, &c.obj))
|
||||
}
|
||||
|
||||
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
|
||||
/// group inside a canvas isn't, nor a hidden member).
|
||||
pub fn pickable(c: &GroupChild) -> bool {
|
||||
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
|
||||
}
|
||||
|
||||
/// Largest canvas side and member offset, points (well past any page).
|
||||
const MAX_SIDE: f32 = 4000.0;
|
||||
/// Smallest canvas side, points.
|
||||
@@ -74,10 +47,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
|
||||
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
|
||||
.when(has_canvas),
|
||||
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
|
||||
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
|
||||
.when(has_canvas)
|
||||
.pure(),
|
||||
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
|
||||
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
|
||||
.when(has_canvas),
|
||||
@@ -255,171 +224,9 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
|
||||
}
|
||||
})?;
|
||||
// The indices after a removed member shift down, so a picked member would name another one.
|
||||
if delete {
|
||||
s.member = None;
|
||||
}
|
||||
result(&o)
|
||||
}
|
||||
|
||||
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
|
||||
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
|
||||
return Err(CmdError::Disabled("select a drawing canvas first".into()));
|
||||
};
|
||||
let index = match v.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
|
||||
}
|
||||
};
|
||||
let Some(i) = index else {
|
||||
s.member = None;
|
||||
return sel_result(s);
|
||||
};
|
||||
let count = children.len();
|
||||
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
|
||||
if !pickable(c) {
|
||||
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
|
||||
}
|
||||
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
|
||||
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
|
||||
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
|
||||
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
|
||||
/// are disabled. `None`: another command, or no member picked.
|
||||
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
|
||||
let replaces = (id.starts_with("text.") && id != "text.overtype")
|
||||
|| matches!(
|
||||
id,
|
||||
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
|
||||
);
|
||||
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
|
||||
return None;
|
||||
}
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(match id {
|
||||
"edit.copy" => copy_member(s, &pos, index),
|
||||
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
|
||||
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
|
||||
remove_member(s, &pos, index).and_then(|_| sel_result(s))
|
||||
}
|
||||
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
|
||||
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
|
||||
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
|
||||
let mut f = s.doc.copy_range(pos, &end);
|
||||
let mut copied = false;
|
||||
for b in &mut f.blocks {
|
||||
let wordcraft_doc::Block::Para(p) = b else { continue };
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Group { children, canvas: Some(_), .. } = o
|
||||
&& let Some(c) = children.get(index)
|
||||
{
|
||||
*o = c.obj.clone();
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copied {
|
||||
return Err(CmdError::Failed("the member vanished".into()));
|
||||
}
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(super::edit::copy_result(s))
|
||||
}
|
||||
|
||||
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
|
||||
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
|
||||
edit_canvas(s, pos, |_, _, children| {
|
||||
if index < children.len() {
|
||||
children.remove(index);
|
||||
}
|
||||
})?;
|
||||
s.member = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
|
||||
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
|
||||
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
|
||||
let (pos, index, obj) = selected_member(s)?;
|
||||
let (w0, h0, _) = obj.frame()?;
|
||||
let min = super::objects::min_size(obj);
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
(None, None) => None,
|
||||
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
|
||||
};
|
||||
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
|
||||
if to.is_some()
|
||||
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
|
||||
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
|
||||
{
|
||||
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
|
||||
}
|
||||
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
|
||||
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
|
||||
if let Some((x, y)) = to {
|
||||
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}
|
||||
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
|
||||
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
|
||||
if (w, h) != (w0, h0) {
|
||||
c.obj.set_size(w, h);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
|
||||
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
|
||||
let (pos, index, _) = selected_member(s)?;
|
||||
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
|
||||
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
|
||||
/// scale of its members' space and the member.
|
||||
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
|
||||
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
|
||||
let canvas =
|
||||
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
|
||||
let scale = canvas.group_scale(frame.w, frame.h);
|
||||
let mut f = Some(f);
|
||||
let canvas = edit_canvas(s, pos, |_, _, children| {
|
||||
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
|
||||
f(frame, scale, c);
|
||||
}
|
||||
})?;
|
||||
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
|
||||
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
|
||||
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
|
||||
}
|
||||
|
||||
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
|
||||
/// canvas. `None`: no member picked there.
|
||||
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
|
||||
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
|
||||
let mut out = None;
|
||||
let r = edit_canvas(s, &at, |_, _, children| {
|
||||
if let Some(c) = children.get_mut(index) {
|
||||
f(&mut c.obj);
|
||||
out = Some(c.obj.clone());
|
||||
}
|
||||
});
|
||||
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
|
||||
}
|
||||
|
||||
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
|
||||
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
|
||||
match o {
|
||||
@@ -520,131 +327,4 @@ mod tests {
|
||||
fn canvas_in_text(s: &Session) -> InlineObject {
|
||||
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
|
||||
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
|
||||
match &children[i].obj {
|
||||
InlineObject::Shape { fill, .. } => fill.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
|
||||
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
|
||||
/// which stays the selection in the text; the commands that place an object in the text
|
||||
/// (wrapping, position) don't apply to it.
|
||||
#[test]
|
||||
fn a_canvas_member_is_selected_and_changed_on_its_own() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
|
||||
let canvas_sel = s.sel.clone();
|
||||
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
|
||||
|
||||
// Shape Fill colours the member, not the canvas or the other member.
|
||||
let other = member_fill(&canvas(&s), 0);
|
||||
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
|
||||
let c = canvas(&s);
|
||||
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
|
||||
assert_eq!(member_fill(&c, 0), other);
|
||||
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
|
||||
assert!(selected_member(&s).is_some(), "still picked after a change");
|
||||
|
||||
// Dragging its frame: the page point becomes its place in the canvas.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
|
||||
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
|
||||
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
|
||||
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
|
||||
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
|
||||
|
||||
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
|
||||
s.run("edit.copy", &json!({})).unwrap();
|
||||
let clip = s.clipboard.clone().unwrap();
|
||||
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
|
||||
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
|
||||
assert!(selected_member(&s).is_none());
|
||||
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
|
||||
// Undo puts the member back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
|
||||
|
||||
// Picking the canvas again (or anything else) ends the member's selection.
|
||||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("select.member", &json!({"index": null})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert!(selected_member(&s).is_none());
|
||||
// Pasted outside the canvas, the copied member is a shape of its own in the text.
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
|
||||
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
|
||||
for (id, v) in [
|
||||
("text.insert", json!({"text": "x"})),
|
||||
("text.newParagraph", json!({})),
|
||||
("edit.paste", json!({"text": "x"})),
|
||||
("edit.pasteText", json!({"text": "x"})),
|
||||
] {
|
||||
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
|
||||
}
|
||||
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
|
||||
// Ctrl+Backspace removes the member alone, like Backspace.
|
||||
s.run("text.deleteWordBack", &json!({})).unwrap();
|
||||
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
|
||||
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
|
||||
assert!(selected_member(&s).is_none());
|
||||
|
||||
// Removing an earlier member through `canvas.member` drops the pick: its index would now
|
||||
// name the next member.
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
|
||||
s.run("select.member", &json!({"index": 1})).unwrap();
|
||||
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
|
||||
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
|
||||
|
||||
// Commands that replace the selection (the whole canvas) are refused too.
|
||||
s.run("select.member", &json!({"index": 0})).unwrap();
|
||||
for (id, v) in [
|
||||
("insert.table", json!({"rows": 1, "cols": 1})),
|
||||
("table.quick", json!({})),
|
||||
("insert.equation", json!({})),
|
||||
("insert.symbol", json!({"char": "§"})),
|
||||
("insert.field", json!({"instr": "AUTHOR"})),
|
||||
("insert.pageBreak", json!({})),
|
||||
] {
|
||||
assert!(s.run(id, &v).is_err(), "{id}");
|
||||
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
|
||||
}
|
||||
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
|
||||
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
|
||||
let before = members(&canvas(&s));
|
||||
let other = frame.page + 1;
|
||||
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
|
||||
assert_eq!(members(&canvas(&s)), before);
|
||||
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
|
||||
assert_ne!(members(&canvas(&s)), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ impl ClipHistory {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
|
||||
@@ -6,9 +6,6 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
|
||||
|
||||
/// Resize and/or move the selected object (dragging its frame or handles).
|
||||
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
|
||||
return r;
|
||||
}
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -164,9 +161,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||||
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
|
||||
};
|
||||
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
|
||||
// page (one anchored on a later page of it is drawn there, #4250).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
|
||||
// Move its character to the anchor (unless it's already in that paragraph).
|
||||
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
|
||||
pos.clone()
|
||||
} else {
|
||||
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
|
||||
@@ -12,10 +12,6 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
// A canvas member moves inside its canvas.
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return None;
|
||||
}
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
|
||||
Some((_, o)) if o.is_floating() => None,
|
||||
@@ -55,15 +51,6 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
|
||||
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
|
||||
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
|
||||
if crate::cmd::canvas::selected_member(s).is_some() {
|
||||
return Some("a shape inside a drawing canvas is placed by the canvas");
|
||||
}
|
||||
has_movable(s)
|
||||
}
|
||||
|
||||
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
|
||||
/// yet, so such a group stays whole.
|
||||
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
|
||||
@@ -112,9 +99,7 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
||||
o.is_drawing().then_some((a, o))
|
||||
}
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
|
||||
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
|
||||
/// the commands that change it go through [`edit_obj`], which changes the member.
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
@@ -122,9 +107,6 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
|
||||
return vec![(pos, member.clone())];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
@@ -193,12 +175,8 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
|
||||
/// picked, the member changes.
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
|
||||
return r;
|
||||
}
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
f(o);
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
|
||||
use super::group::{add_object, group, ungroup};
|
||||
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
|
||||
use super::select::{
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
|
||||
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
|
||||
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
|
||||
};
|
||||
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
|
||||
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
|
||||
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
with_float(s, |f| f.wrap = wrap)
|
||||
})
|
||||
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
|
||||
.when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
|
||||
let side = p::str(v, "side").unwrap_or("bothSides");
|
||||
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
|
||||
with_float(s, |f| f.wrap_text = side)
|
||||
})
|
||||
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
|
||||
.when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
|
||||
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
|
||||
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
|
||||
.when(has_floating),
|
||||
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
|
||||
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
|
||||
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
|
||||
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
|
||||
.when(has_placeable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
|
||||
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
|
||||
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
|
||||
let h = p::str(v, "value").unwrap_or("center").to_string();
|
||||
let tw = crate::cmd::page::sect(s).text_width();
|
||||
@@ -245,20 +245,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"value": "left|center|right"}"#)
|
||||
.when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
|
||||
.when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
let (dx, dy) = (d("dx"), d("dy"));
|
||||
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
|
||||
return r;
|
||||
}
|
||||
unalign(s)?;
|
||||
with_float(s, |f| {
|
||||
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
@@ -14,69 +14,6 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
|
||||
}
|
||||
|
||||
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
|
||||
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
|
||||
/// thrown back to the page the paragraph starts on. One undo step puts it back.
|
||||
#[test]
|
||||
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
assert!(s.layout().pages.len() > 2);
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let back = object_area(&mut s);
|
||||
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
|
||||
}
|
||||
|
||||
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
|
||||
/// goes back to the paragraph's start rather than staying on the later page's line.
|
||||
#[test]
|
||||
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
|
||||
let before = (object_area(&mut s), s.undo_depth());
|
||||
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
|
||||
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
assert_eq!(s.undo_depth(), before.1 + 1);
|
||||
}
|
||||
|
||||
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
|
||||
/// on the page it was dropped on, anchored in that line.
|
||||
#[test]
|
||||
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
|
||||
assert_eq!(s.doc.body.len(), 1, "one paragraph");
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -219,10 +219,6 @@ pub struct Session {
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
|
||||
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
|
||||
/// any selection change, caret movement or undo.
|
||||
pub member: Option<crate::cmd::canvas::CanvasMember>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
|
||||
@@ -276,8 +276,6 @@ impl Session {
|
||||
ime_state_exhausted: _,
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
|
||||
member: _,
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
@@ -378,8 +376,6 @@ impl Session {
|
||||
swap(&mut self.widened, widened);
|
||||
swap(&mut self.edit_marks, edit_marks);
|
||||
swap(&mut self.go_back_step, go_back_step);
|
||||
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
|
||||
self.member = None;
|
||||
}
|
||||
|
||||
/// Make document `id` active without anything else a switch by the user does.
|
||||
|
||||
@@ -150,7 +150,6 @@ impl Session {
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected,
|
||||
member: None,
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
|
||||
@@ -185,10 +185,7 @@ impl Session {
|
||||
let mutates = spec.mutates;
|
||||
let ime_caret_before = self.current_ime_caret_state();
|
||||
let rev_before = self.rev;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
|
||||
Some(r) => r,
|
||||
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
|
||||
}));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -394,15 +391,6 @@ impl Session {
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
// A canvas member stays picked while its canvas does and nothing else is selected.
|
||||
if id != "select.member"
|
||||
&& (self.sel != *sel_before
|
||||
|| id.starts_with("select.")
|
||||
|| id.starts_with("caret.")
|
||||
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
|
||||
{
|
||||
self.member = None;
|
||||
}
|
||||
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ impl Session {
|
||||
self.pending = None;
|
||||
self.typing = None;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
@@ -156,7 +155,6 @@ impl Session {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
member: _,
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
@@ -213,7 +211,6 @@ impl Session {
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
@@ -141,8 +141,6 @@ impl Session {
|
||||
self.page_hint = page_hint;
|
||||
self.column_mode = column_mode;
|
||||
self.math = math;
|
||||
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
|
||||
self.member = None;
|
||||
if current {
|
||||
self.hf_return = hf_return;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
@@ -332,14 +332,7 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
|
||||
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame {
|
||||
page: frame,
|
||||
col: (0.0, width),
|
||||
para_y: para_top,
|
||||
table_chr: table.map(|t| &t.chr),
|
||||
pinned: Some(&pinned),
|
||||
late: None,
|
||||
};
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let (back, front) = place_objects(
|
||||
ctx,
|
||||
&ObjectPlacement {
|
||||
|
||||
+12
-263
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
|
||||
use super::placed::Placed;
|
||||
use super::table_flow::footer_table_wraps;
|
||||
use super::{
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
|
||||
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
|
||||
StoryRef, Wrap, WrapText, contour, display, para, textbox,
|
||||
};
|
||||
|
||||
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
|
||||
@@ -336,133 +336,14 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
|
||||
/// broken again for another column) the floats stay where its first piece placed and drew them:
|
||||
/// they aren't placed again in the new column, and their record for the page they are drawn on
|
||||
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
|
||||
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
|
||||
/// ([`EarlyFloats`]) stands.
|
||||
pub(crate) fn anchor_floats(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
force: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
|
||||
let FloatAnchor { p, block, y0, place, .. } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
// Each layout of it here counts its automatic equation numbers from the same place.
|
||||
let eq0 = ctx.eq_count;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
|
||||
if !place || pb.web || p.objects.is_empty() {
|
||||
return (rects, pl, rel, force.to_vec());
|
||||
}
|
||||
// Not those left out, nor those the page started with (#2048 settled them here).
|
||||
let page = pb.pages.len().saturating_sub(1);
|
||||
let seeded: Vec<usize> =
|
||||
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
|
||||
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
|
||||
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
|
||||
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
let mut late: Vec<usize> =
|
||||
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
|
||||
late.sort_unstable();
|
||||
late.dedup();
|
||||
if late.as_slice() == force {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
|
||||
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
|
||||
let still = late_floats(p, &pl, fit(&pl));
|
||||
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
|
||||
if fewer.len() == late.len() {
|
||||
return (rects, pl, rel, late);
|
||||
}
|
||||
ctx.eq_count = eq0;
|
||||
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
|
||||
(rects, pl, rel, fewer)
|
||||
}
|
||||
|
||||
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
|
||||
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
|
||||
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
|
||||
let lines = anchor_lines(p, pl);
|
||||
let late =
|
||||
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
|
||||
p.objects.iter().enumerate().filter_map(late).collect()
|
||||
}
|
||||
|
||||
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
|
||||
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
|
||||
/// page break ends them, a column break its column).
|
||||
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
|
||||
let Some(first) = pl.lines.get(l0) else { return l0 };
|
||||
// A line's place in its column: `shift` plus its top.
|
||||
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
|
||||
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
|
||||
if li > start && shift + l.top + l.height > bottom + 0.01 {
|
||||
if more == 0 {
|
||||
break;
|
||||
}
|
||||
(more, start, shift) = (more - 1, li, col_top - l.top);
|
||||
}
|
||||
fit = li + 1;
|
||||
match l.end {
|
||||
LineEnd::PageBreak => break,
|
||||
LineEnd::ColumnBreak if more == 0 => break,
|
||||
LineEnd::ColumnBreak => {
|
||||
more -= 1;
|
||||
start = li + 1;
|
||||
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fit
|
||||
}
|
||||
|
||||
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
|
||||
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
|
||||
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
|
||||
let Some(o) = p.objects.get(oi) else { return };
|
||||
let Some((w, h, float)) = floating(o) else { return };
|
||||
let h = ctx.float_height(o, w, h);
|
||||
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
|
||||
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
|
||||
let bounds = a.0;
|
||||
pb.excl.push(a);
|
||||
bounds
|
||||
});
|
||||
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
|
||||
}
|
||||
|
||||
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
*slot = Some(if lead { li.saturating_sub(1) } else { li });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`anchor_floats`], leaving out the floats in `late` (in order).
|
||||
fn anchor_floats_but(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder,
|
||||
anchor: FloatAnchor,
|
||||
late: &[usize],
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
|
||||
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
|
||||
ctx.fields.page = pb.number;
|
||||
let (col_x, width) = (pb.col_x(), pb.col_w());
|
||||
let mut rects = HashMap::new();
|
||||
if !pb.web && place {
|
||||
@@ -482,10 +363,6 @@ fn anchor_floats_but(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
if late.binary_search(&oi).is_ok() {
|
||||
ctx.floats_above.remove(&(block, oi));
|
||||
continue;
|
||||
}
|
||||
let h = ctx.float_height(o, w, h);
|
||||
// Positions relative to the paragraph start above its space before (Word for Windows).
|
||||
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
|
||||
@@ -537,123 +414,6 @@ pub(crate) struct ObjFrame<'a> {
|
||||
/// Floats placed on the page from a header or footer cell (by object index, in page
|
||||
/// coordinates): their items go to [`Ctx::pin`].
|
||||
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
|
||||
/// A body paragraph's floats that go on a later page or column than its first piece.
|
||||
pub(crate) late: Option<&'a LateFloats>,
|
||||
}
|
||||
|
||||
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
|
||||
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct LatePlace {
|
||||
pub(crate) rect: Rect,
|
||||
pub(crate) top: f32,
|
||||
pub(crate) spot: (usize, usize),
|
||||
pub(crate) drawn: bool,
|
||||
pub(crate) area: Option<Rect>,
|
||||
}
|
||||
|
||||
/// A body paragraph's floating objects anchored on a later page than its first line
|
||||
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
|
||||
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
|
||||
pub(crate) struct LateFloats {
|
||||
/// By object index, where it was placed, once it is.
|
||||
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
|
||||
/// The page and column of the piece being placed.
|
||||
pub(crate) piece: (usize, usize),
|
||||
/// [`anchor_lines`], and the paragraph layout they're for.
|
||||
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
|
||||
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
|
||||
/// ([`LateFloats::keep_back`]): (piece, object index).
|
||||
pub(crate) held: Vec<((usize, usize), usize)>,
|
||||
pub(crate) kept: Vec<((usize, usize), usize)>,
|
||||
}
|
||||
|
||||
impl LateFloats {
|
||||
pub(crate) fn new(late: &[usize]) -> Self {
|
||||
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
|
||||
}
|
||||
|
||||
/// [`anchor_lines`] for `pl`.
|
||||
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
|
||||
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
|
||||
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The line object `oi` goes with ([`anchor_lines`]).
|
||||
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
|
||||
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
|
||||
}
|
||||
|
||||
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
|
||||
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
|
||||
/// their wrap areas out of `excl`. Whether there were any.
|
||||
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let out: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
|
||||
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
for oi in &out {
|
||||
if let Some(Some(a)) = self.placed.insert(*oi, None)
|
||||
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
|
||||
{
|
||||
excl.remove(k);
|
||||
}
|
||||
self.held.push((piece, *oi));
|
||||
}
|
||||
!out.is_empty()
|
||||
}
|
||||
|
||||
/// Those this piece let go whose anchor line came back into `lines` without them (their own
|
||||
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
|
||||
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
|
||||
self.refresh(p, pl);
|
||||
let piece = self.piece;
|
||||
let back: Vec<_> =
|
||||
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
|
||||
self.held.retain(|h| !back.contains(h));
|
||||
self.kept.extend(&back);
|
||||
!back.is_empty()
|
||||
}
|
||||
|
||||
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
|
||||
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
|
||||
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
|
||||
self.refresh(p, pl);
|
||||
let mut due: Vec<usize> = self
|
||||
.placed
|
||||
.iter()
|
||||
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
|
||||
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
|
||||
.map(|(oi, _)| *oi)
|
||||
.collect();
|
||||
due.sort_unstable();
|
||||
due
|
||||
}
|
||||
|
||||
/// Late float `oi` if it's drawn with this piece.
|
||||
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
|
||||
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
|
||||
}
|
||||
|
||||
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
|
||||
/// break is on the line after its [`anchor_lines`] one).
|
||||
pub(crate) fn lines_here(&self) -> Vec<usize> {
|
||||
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
|
||||
}
|
||||
|
||||
/// Those drawn with this piece are drawn.
|
||||
pub(crate) fn drew(&mut self) {
|
||||
let piece = self.piece;
|
||||
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
|
||||
a.drawn = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
|
||||
@@ -701,15 +461,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
|
||||
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
|
||||
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
|
||||
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
|
||||
// (#4250). Inline objects go with their own line.
|
||||
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
|
||||
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
|
||||
// box with the title; the box stays on the cover). So the paragraph's first piece places
|
||||
// every floating object, whichever of its lines holds the anchor, and later pieces none;
|
||||
// inline objects go with their own line.
|
||||
let first = l0 == 0;
|
||||
let late = at.late.filter(|l| !l.placed.is_empty());
|
||||
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
|
||||
for li in 0..pl.lines.len() {
|
||||
let in_piece = (l0..l1).contains(&li);
|
||||
if !in_piece && !first && !late_lines.contains(&li) {
|
||||
if !in_piece && !first {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
@@ -722,23 +482,12 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let Some((w, h, float)) = obj.frame() else { continue };
|
||||
let floating = float.wrap != Wrap::Inline;
|
||||
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
|
||||
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
|
||||
let skip = if is_late {
|
||||
late_here.is_none()
|
||||
} else if floating {
|
||||
!first
|
||||
} else {
|
||||
!in_piece
|
||||
};
|
||||
if skip {
|
||||
if if floating { !first } else { !in_piece } {
|
||||
continue;
|
||||
}
|
||||
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
|
||||
let rect = if let Some(r) = pinned {
|
||||
r
|
||||
} else if let Some(l) = late_here {
|
||||
l.rect
|
||||
} else if floating {
|
||||
match floats.get(&oi) {
|
||||
Some(r) => *r,
|
||||
@@ -773,7 +522,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
off: c.start,
|
||||
text_box: shown,
|
||||
wrap: float.wrap,
|
||||
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
|
||||
origin: Point::new(at.col.0, at.para_y),
|
||||
});
|
||||
// Its text (a group: its text boxes', each where the group's turn carries it; the
|
||||
// text itself stays upright), unless the box budget says no (a box inside itself,
|
||||
|
||||
+11
-116
@@ -1,10 +1,7 @@
|
||||
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
|
||||
use super::cache::has_page_number_fields;
|
||||
use super::ctx::Ctx;
|
||||
use super::floats::{
|
||||
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
|
||||
place_objects,
|
||||
};
|
||||
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
|
||||
use super::notes::{DECOR_STORY, NoteBox, para_notes};
|
||||
use super::page::{PageBuilder, PageFrame, item_bottom};
|
||||
use super::placed::Placed;
|
||||
@@ -175,15 +172,6 @@ struct Flow<'a> {
|
||||
wrap_relays: usize,
|
||||
/// The first line not placed yet.
|
||||
l0: usize,
|
||||
/// Its floats anchored on a later page or column than its first piece (#4250).
|
||||
late: LateFloats,
|
||||
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
|
||||
/// it held back.
|
||||
rechecked: bool,
|
||||
forced: Vec<usize>,
|
||||
/// The space before it at the top of the piece being placed (a paragraph starting with a page
|
||||
/// break has it on the new page): late floats there are positioned from above it.
|
||||
space_here: f32,
|
||||
}
|
||||
|
||||
impl<'a> Flow<'a> {
|
||||
@@ -197,8 +185,8 @@ impl<'a> Flow<'a> {
|
||||
let guess = pb.y + space_before;
|
||||
let label = ctx.next_label(p);
|
||||
let eq_start = ctx.eq_count;
|
||||
let (float_rects, pl, rel, late) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
|
||||
let (float_rects, pl, rel) =
|
||||
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
|
||||
Flow {
|
||||
p,
|
||||
block,
|
||||
@@ -218,10 +206,6 @@ impl<'a> Flow<'a> {
|
||||
page_relays: 0,
|
||||
wrap_relays: 0,
|
||||
l0: 0,
|
||||
late: LateFloats::new(&late),
|
||||
rechecked: false,
|
||||
forced: Vec::new(),
|
||||
space_here: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,19 +213,16 @@ impl<'a> Flow<'a> {
|
||||
self.pl.lines.len()
|
||||
}
|
||||
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
|
||||
/// leaving those in `force` for a later piece.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
|
||||
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
|
||||
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
|
||||
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
|
||||
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
|
||||
anchor_floats(
|
||||
ctx,
|
||||
pb,
|
||||
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
|
||||
force,
|
||||
)
|
||||
});
|
||||
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
|
||||
self.late = LateFloats::new(&late);
|
||||
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
|
||||
}
|
||||
|
||||
@@ -492,10 +473,8 @@ impl<'a> Flow<'a> {
|
||||
pb.advance(self.block, self.body_top);
|
||||
if self.l0 == 0 {
|
||||
let y0 = pb.y;
|
||||
self.reanchor(ctx, pb, (y0, 0.0), &[]);
|
||||
self.reanchor(ctx, pb, (y0, 0.0));
|
||||
self.anchor_y = y0;
|
||||
// Its floats are checked again on the new page.
|
||||
(self.rechecked, self.forced) = (false, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,13 +507,6 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
|
||||
pb.para_end = Some((pb.pages.len(), pb.col, end));
|
||||
// Late floats whose anchor line came here after all are placed with these lines.
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
if !pb.web && !self.late.placed.is_empty() {
|
||||
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
|
||||
self.place_due(ctx, pb, 0..l1, top);
|
||||
}
|
||||
self.space_here = 0.0;
|
||||
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
|
||||
let at = ObjFrame {
|
||||
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
|
||||
@@ -542,7 +514,6 @@ impl<'a> Flow<'a> {
|
||||
para_y: if pb.web { y } else { self.anchor_y },
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: Some(&self.late),
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
@@ -558,7 +529,6 @@ impl<'a> Flow<'a> {
|
||||
depth: 0,
|
||||
},
|
||||
);
|
||||
self.late.drew();
|
||||
items.extend(front);
|
||||
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
|
||||
add_items(pb, behind, items);
|
||||
@@ -585,8 +555,7 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
|
||||
pb.new_page(block, body_top);
|
||||
self.space_here = self.pl.rp.space_before.max(0.0);
|
||||
pb.y += self.space_here;
|
||||
pb.y += self.pl.rp.space_before.max(0.0);
|
||||
}
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => {
|
||||
@@ -609,8 +578,8 @@ impl<'a> Flow<'a> {
|
||||
let y0 = pb.y;
|
||||
// The floats were placed and drawn with the first piece: they stay there.
|
||||
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
|
||||
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
|
||||
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
|
||||
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
|
||||
});
|
||||
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
|
||||
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
|
||||
@@ -620,72 +589,6 @@ impl<'a> Flow<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
|
||||
/// are broken again around them ([`Flow::relay_wrap`]).
|
||||
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
|
||||
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.late.piece = (pb.pages.len(), pb.col);
|
||||
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
|
||||
self.place_due(ctx, pb, lines, pb.y - self.space_here);
|
||||
}
|
||||
|
||||
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
|
||||
/// `top`.
|
||||
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
|
||||
for oi in self.late.due(self.p, &self.pl, lines) {
|
||||
place_late(ctx, pb, self.p, oi, top, &mut self.late);
|
||||
}
|
||||
}
|
||||
|
||||
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
|
||||
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
|
||||
/// be broken again without them. Does nothing once the wrap relays are used up
|
||||
/// ([`MAX_WRAP_RELAYS`]).
|
||||
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
let l0 = self.l0;
|
||||
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
|
||||
return false;
|
||||
}
|
||||
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
|
||||
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
|
||||
moved && l1 > l0
|
||||
}
|
||||
|
||||
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
|
||||
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
|
||||
/// are held back after all, once; and those held back whose anchor came back once they were
|
||||
/// out of the way are placed here as before, with the text wrapping around them rather than
|
||||
/// over it (before the notes of these lines are kept: the wrap can push their references on).
|
||||
/// Whether the paragraph was laid out again.
|
||||
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
|
||||
if self.l0 != 0 || pb.web {
|
||||
return false;
|
||||
}
|
||||
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
|
||||
self.rechecked = true;
|
||||
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
|
||||
if !self.forced.is_empty() {
|
||||
let mut force = self.forced.clone();
|
||||
force.extend(self.late.placed.keys());
|
||||
force.sort_unstable();
|
||||
let top = (pb.y, pb.y - self.anchor_y);
|
||||
self.reanchor(ctx, pb, top, &force);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
|
||||
if !back.iter().any(|oi| self.forced.contains(oi)) {
|
||||
return false;
|
||||
}
|
||||
// The others are still held back.
|
||||
self.forced.retain(|oi| !back.contains(oi));
|
||||
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
|
||||
self.reanchor(ctx, pb, top, &keep);
|
||||
true
|
||||
}
|
||||
|
||||
/// Leave the space after the paragraph, for the next one to merge with.
|
||||
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
|
||||
// A mark that stayed with its page break keeps its space after on that page too, and its
|
||||
@@ -827,11 +730,10 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
// A different top only matters when there is something to wrap around.
|
||||
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
|
||||
let y0 = pb.y;
|
||||
f.reanchor(ctx, pb, (y0, applied_before), &[]);
|
||||
f.reanchor(ctx, pb, (y0, applied_before));
|
||||
f.anchor_y = y0 - applied_before;
|
||||
}
|
||||
while f.l0 < f.n() {
|
||||
f.place_due_late(ctx, pb);
|
||||
f.relay_wrap(ctx, pb);
|
||||
f.relay_pages(ctx, pb);
|
||||
// What is laid out for these lines from here (their footnotes) is on this page.
|
||||
@@ -842,9 +744,6 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let notes = para_notes(ctx.doc, p, &f.pl);
|
||||
let mut fit = f.fit_lines(ctx, pb, ¬es, lead);
|
||||
fit.l1 = f.settled_end(pb, &fit);
|
||||
if f.settle_late(pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
if fit.l1 == f.l0 {
|
||||
if !pb.fresh() {
|
||||
f.move_on(ctx, pb);
|
||||
@@ -852,9 +751,6 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
}
|
||||
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
|
||||
}
|
||||
if f.recheck_late(ctx, pb, fit.l1) {
|
||||
continue;
|
||||
}
|
||||
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
|
||||
f.emit(ctx, pb, rooms, (lead, fit, ¬es));
|
||||
f.after_lines(ctx, pb, forced, mark_with_break);
|
||||
@@ -915,7 +811,6 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
|
||||
para_y: y,
|
||||
table_chr: None,
|
||||
pinned: None,
|
||||
late: None,
|
||||
};
|
||||
let (behind, front) = place_objects(
|
||||
ctx,
|
||||
|
||||
@@ -72,9 +72,6 @@ pub struct ObjectHit {
|
||||
pub wrap: Wrap,
|
||||
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
|
||||
pub origin: Point,
|
||||
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
|
||||
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
|
||||
pub member: Option<usize>,
|
||||
}
|
||||
|
||||
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
|
||||
@@ -152,7 +149,6 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
|
||||
text_box: *text_box,
|
||||
wrap: *wrap,
|
||||
origin: *origin,
|
||||
member: None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
|
||||
pub use grid::{Exclusion, Grid, is_east_asian};
|
||||
#[cfg(test)]
|
||||
use lines::break_lines;
|
||||
pub(crate) use lines::takes_room;
|
||||
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
|
||||
pub use paragraph::layout_para;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
|
||||
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
|
||||
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
|
||||
/// leaving the anchor a line of its own).
|
||||
pub(crate) fn takes_room(c: &Cluster) -> bool {
|
||||
fn takes_room(c: &Cluster) -> bool {
|
||||
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
|
||||
}
|
||||
|
||||
|
||||
+3
-343
@@ -6036,8 +6036,9 @@ fn float_follows_its_paragraph_to_the_next_page() {
|
||||
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
|
||||
}
|
||||
|
||||
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
|
||||
/// with the title: with nothing after its anchor, the box stays on the cover.
|
||||
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
|
||||
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
|
||||
/// break and then the page-anchored text box with the title; the box stays on the cover.
|
||||
#[test]
|
||||
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
@@ -6050,347 +6051,6 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
|
||||
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
|
||||
}
|
||||
|
||||
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
|
||||
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
|
||||
/// and the box with it.
|
||||
#[test]
|
||||
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
|
||||
let cover = |after: InlineObject| {
|
||||
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
|
||||
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
// The floating box; the inline picture is 20 pt wide.
|
||||
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
|
||||
(boxes(0), boxes(1))
|
||||
};
|
||||
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
|
||||
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
|
||||
}
|
||||
|
||||
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
|
||||
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
|
||||
/// first line after a page break inside a paragraph is anchored right after the break and stays
|
||||
/// on the new page, positioned from the paragraph's top there.
|
||||
#[test]
|
||||
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = "The quick brown fox jumps over the lazy dog. ";
|
||||
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
|
||||
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
let (top, ..) = lines_on(&l, 1)[0];
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
}
|
||||
|
||||
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
|
||||
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for it in &l.pages[pi].items {
|
||||
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
|
||||
&& path.0 == [0]
|
||||
{
|
||||
let first = para.lines[*l0].top;
|
||||
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
|
||||
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
|
||||
/// That page's text wraps around it; the page before is laid out as without it.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
|
||||
use wordcraft_doc::para::Wrap;
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
|
||||
let on_two = shapes(&l.pages[1].items);
|
||||
assert_eq!(on_two.len(), 1, "{on_two:?}");
|
||||
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
|
||||
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
|
||||
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
|
||||
// Its hit area is there too, for selecting and dragging it.
|
||||
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
|
||||
assert_eq!(hit.page, 1);
|
||||
}
|
||||
|
||||
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
|
||||
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
|
||||
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
|
||||
#[test]
|
||||
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let plain = lay(&d);
|
||||
let (top, _, _) = lines_on(&plain, 1)[0];
|
||||
let anchor = lines_on(&plain, 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
|
||||
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
|
||||
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
|
||||
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
|
||||
}
|
||||
|
||||
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
|
||||
/// from its paragraph anchored there is still measured from the paragraph's top, above that
|
||||
/// space, as on its first page.
|
||||
#[test]
|
||||
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
|
||||
let mut d = Document::from_text(&text);
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
|
||||
p.props.space_before = Some(24.0);
|
||||
}
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
|
||||
}
|
||||
|
||||
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
|
||||
/// but a picture positioned from its paragraph on page two stays where it is there.
|
||||
#[test]
|
||||
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let anchor = lines_on(&lay(&d), 1)[8].2;
|
||||
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
|
||||
let before = shapes(&lay(&d).pages[1].items);
|
||||
for _ in 0..3 {
|
||||
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
|
||||
}
|
||||
let l = lay(&d);
|
||||
assert!(shapes(&l.pages[0].items).is_empty());
|
||||
assert_eq!(shapes(&l.pages[1].items), before);
|
||||
}
|
||||
|
||||
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
|
||||
#[test]
|
||||
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let plain = lay(&d);
|
||||
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
|
||||
let anchor = lines_on(&plain, 2)[3].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
assert_eq!(per_page.iter().sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
|
||||
/// line `k` of page two as laid out without it.
|
||||
fn footnoted_float(k: usize) -> DocLayout {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), 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(0, 4),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[k].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
lay(&d)
|
||||
}
|
||||
|
||||
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
|
||||
/// anchored a few lines into page two is drawn on page two all the same.
|
||||
#[test]
|
||||
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
|
||||
let l = footnoted_float(3);
|
||||
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1);
|
||||
}
|
||||
|
||||
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
|
||||
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
|
||||
#[test]
|
||||
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
|
||||
let l = footnoted_float(0);
|
||||
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
|
||||
/// back to page two; the note goes with it, once, not left on page one as well.
|
||||
#[test]
|
||||
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let note = |text: &str| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
|
||||
p.props.space_after = Some(0.0);
|
||||
vec![wordcraft_doc::para_block(p)]
|
||||
};
|
||||
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
|
||||
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
|
||||
let anchor = lines_on(&lay(&d), 1)[0].2;
|
||||
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
|
||||
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
|
||||
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
|
||||
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
|
||||
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
|
||||
let notes: Vec<usize> = l
|
||||
.pages
|
||||
.iter()
|
||||
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
|
||||
.collect();
|
||||
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
|
||||
}
|
||||
|
||||
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
|
||||
/// there; the other is still drawn on page two.
|
||||
#[test]
|
||||
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), 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)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
|
||||
let lines = lines_on(&lay(&d), 1);
|
||||
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
|
||||
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
|
||||
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
|
||||
}
|
||||
|
||||
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
|
||||
/// a float anchored on the first line it pushes on to page three is drawn on page three.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), 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)]);
|
||||
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let at = lines_on(&lay(&d), 1)[1].2;
|
||||
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
|
||||
// A word into the line: an anchor at its very start goes with the line before.
|
||||
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
|
||||
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
|
||||
/// float anchored there is drawn on that page.
|
||||
#[test]
|
||||
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
|
||||
// A paragraph of three pages whose last two lines are on the third only by widow control.
|
||||
let (mut d, l) = (150..300)
|
||||
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
|
||||
.map(|d| {
|
||||
let l = lay(&d);
|
||||
(d, l)
|
||||
})
|
||||
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
|
||||
.expect("a paragraph ending in a widow");
|
||||
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
|
||||
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(per_page, [0, 0, 1]);
|
||||
}
|
||||
|
||||
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
|
||||
/// whose lines it was placed with, as before, with the text there wrapping around it.
|
||||
#[test]
|
||||
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
|
||||
let last = *lines_on(&lay(&d), 1).last().unwrap();
|
||||
let anchor = last.2 + "The ".len();
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
|
||||
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
|
||||
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
|
||||
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
|
||||
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
|
||||
/// equation numbers aren't counted twice: they read (1), (2).
|
||||
#[test]
|
||||
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
|
||||
let eq = |s: &str| InlineObject::Equation {
|
||||
linear: s.into(),
|
||||
display: true,
|
||||
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
|
||||
};
|
||||
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
|
||||
let anchor = lines_on(&lay(&d), 1)[5].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
|
||||
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
|
||||
}
|
||||
|
||||
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
|
||||
/// the same page is placed with its first piece, as before, so the first column wraps around it.
|
||||
#[test]
|
||||
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
|
||||
d.last_section.columns.count = 2;
|
||||
let plain = lay(&d);
|
||||
let lines = lines_on(&plain, 0);
|
||||
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
|
||||
let anchor = lines[second + 2].2;
|
||||
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
|
||||
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
|
||||
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
|
||||
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
|
||||
}
|
||||
|
||||
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
|
||||
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
|
||||
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
|
||||
|
||||
@@ -324,20 +324,6 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
|
||||
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
|
||||
}
|
||||
|
||||
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
|
||||
/// text's height too.
|
||||
#[test]
|
||||
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
|
||||
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
|
||||
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
|
||||
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
|
||||
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
|
||||
assert!(hit.page > 0, "drawn on the page of its anchor");
|
||||
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_linked_box_turns_only_its_own_lines() {
|
||||
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
|
||||
|
||||
@@ -736,7 +736,7 @@ impl WordApp {
|
||||
json!({"dir": dir})
|
||||
}
|
||||
// The built-in file browser (#576), for when the system's file dialog doesn't show up
|
||||
// (desktop only): `purpose` is document (default), picture, recipients, save or export. A system
|
||||
// (desktop only): `purpose` is document (default), picture, recipients or save. A system
|
||||
// dialog still waiting for an answer is given up as cancelled.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"ui.fileBrowser" => {
|
||||
|
||||
@@ -689,11 +689,6 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() {
|
||||
app.save_as_dialog();
|
||||
}
|
||||
// Linux and the BSDs (#3535): WordCraft's own browser when the system dialog doesn't appear.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
if ui.button(egui::RichText::new(tl!("Browse in WordCraft…")).font(medium(14.0))).clicked() {
|
||||
let _ = app.run("ui.fileBrowser", json!({"purpose": "save"}));
|
||||
}
|
||||
// A Word template in the user templates folder, which File › New lists under Personal (#2852).
|
||||
ui.add_space(8.0);
|
||||
if ui
|
||||
|
||||
@@ -809,77 +809,6 @@ fn save_as_page_shows_browse_and_no_open_list() {
|
||||
assert!(h.query_by_label("Recent").is_none(), "Save As has no list that opens documents");
|
||||
}
|
||||
|
||||
/// #3467, #3535: a system dialog that never shows up doesn't leave Save As stuck: Browse in
|
||||
/// WordCraft… gives it up and saves through WordCraft's own browser.
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn save_as_saves_in_wordcraft_when_the_system_dialog_never_shows() {
|
||||
// A host that takes every request and never answers, like a portal that shows nothing.
|
||||
let senders: std::rc::Rc<std::cell::RefCell<Vec<std::sync::mpsc::Sender<crate::FileDialogAnswer>>>> = Default::default();
|
||||
let keep = senders.clone();
|
||||
let services = Services {
|
||||
file_dialog: Some(Box::new(move |_| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
keep.borrow_mut().push(tx);
|
||||
rx
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-backstage-builtin-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
a.run("text.insert", json!({"text": "Saved past a silent portal"})).unwrap();
|
||||
a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Browse…").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().file_dialog.is_some(), "the system dialog was asked for and never answered");
|
||||
h.get_by_label("Browse in WordCraft…").click();
|
||||
h.run_steps(4);
|
||||
let a = h.state_mut();
|
||||
assert!(a.file_dialog.is_none(), "the silent system dialog is given up");
|
||||
let b = a.file_browser.open.as_mut().expect("WordCraft's own browser is open");
|
||||
b.navigate(dir.clone());
|
||||
b.name = "kept".into();
|
||||
let crate::file_browser::Step::Pick(path) = b.accept() else { panic!("picked") };
|
||||
a.close_file_browser(Some(path));
|
||||
let saved = dir.join("kept.docx");
|
||||
assert_eq!(a.session.path.as_deref(), Some(saved.as_path()));
|
||||
let back = wordcraft_engine::io::open_path(&saved).unwrap();
|
||||
assert!(back.plain_text(wordcraft_doc::StoryRef::Body).contains("Saved past a silent portal"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3535: File › Export offers WordCraft's own browser as well, for its formats.
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn export_page_offers_wordcrafts_own_browser() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("ui.backstage", json!({"value": true, "page": "export"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Browse in WordCraft…").click();
|
||||
h.run_steps(4);
|
||||
let b = h.state().file_browser.open.as_ref().expect("WordCraft's own browser is open");
|
||||
assert!(matches!(&b.after, crate::file_dialogs::AfterPick::Export { ext } if ext == "pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backstage_save_navigates_to_save_as_when_unnamed() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
|
||||
@@ -12,7 +12,6 @@ use std::path::{Path, PathBuf};
|
||||
use egui::{Align2, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
use crate::file_dialogs::{AfterPick, FileDialogRequest, open_filters};
|
||||
use crate::print_page::{EXPORT_FORMATS, export_file_ext};
|
||||
use crate::theme::{Tokens, regular};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
@@ -139,8 +138,6 @@ pub(crate) struct FileBrowser {
|
||||
/// The file types, as (label, extensions).
|
||||
filters: Vec<(String, Vec<String>)>,
|
||||
filter: usize,
|
||||
/// Exporting: the format behind each type, which `after` follows; empty otherwise.
|
||||
exports: Vec<String>,
|
||||
listing: Result<Listing, String>,
|
||||
/// The highlighted entry.
|
||||
selected: Option<PathBuf>,
|
||||
@@ -161,15 +158,6 @@ impl FileBrowser {
|
||||
(name.clone(), f)
|
||||
}
|
||||
};
|
||||
// Exporting (#3535): File › Export's formats are the types, starting on the one asked for.
|
||||
let (filters, filter, exports) = match &after {
|
||||
AfterPick::Export { ext } => (
|
||||
EXPORT_FORMATS.iter().map(|&(l, f)| (l.to_string(), vec![export_file_ext(f).to_string()])).collect(),
|
||||
EXPORT_FORMATS.iter().position(|&(_, f)| f == ext.as_str()).unwrap_or(0),
|
||||
EXPORT_FORMATS.iter().map(|&(_, f)| f.to_string()).collect(),
|
||||
),
|
||||
_ => (filters, 0, Vec::new()),
|
||||
};
|
||||
let mut b = FileBrowser {
|
||||
req,
|
||||
after,
|
||||
@@ -178,8 +166,7 @@ impl FileBrowser {
|
||||
location: String::new(),
|
||||
name,
|
||||
filters,
|
||||
filter,
|
||||
exports,
|
||||
filter: 0,
|
||||
listing: Ok(Listing::default()),
|
||||
selected: None,
|
||||
note: Note::None,
|
||||
@@ -202,18 +189,6 @@ impl FileBrowser {
|
||||
self.note = Note::None;
|
||||
}
|
||||
|
||||
/// A type was chosen: when exporting it is the format, and the name's export extension follows it.
|
||||
fn type_changed(&mut self) {
|
||||
let Some(format) = self.exports.get(self.filter).cloned() else { return };
|
||||
let name = Path::new(&self.name);
|
||||
let known =
|
||||
name.extension().and_then(|e| e.to_str()).is_some_and(|e| self.exports.iter().any(|f| export_file_ext(f).eq_ignore_ascii_case(e)));
|
||||
if known {
|
||||
self.name = name.with_extension(export_file_ext(&format)).to_string_lossy().into_owned();
|
||||
}
|
||||
self.after = AfterPick::Export { ext: format };
|
||||
}
|
||||
|
||||
fn up(&mut self) {
|
||||
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
|
||||
self.navigate(parent);
|
||||
@@ -221,8 +196,8 @@ impl FileBrowser {
|
||||
}
|
||||
|
||||
/// OK: a highlighted folder, or a typed folder, is opened; otherwise the typed name is the
|
||||
/// pick. Save adds the chosen type's extension to a name without one (an export, to a name
|
||||
/// without the export's) and asks once more before replacing a file; Open wants a file that exists.
|
||||
/// pick. Save adds the chosen type's extension to a name without one and asks once more
|
||||
/// before replacing a file; Open wants a file that exists.
|
||||
pub(crate) fn accept(&mut self) -> Step {
|
||||
if let Some(dir) = self.selected.take().filter(|p| p.is_dir()) {
|
||||
self.navigate(dir);
|
||||
@@ -243,13 +218,7 @@ impl FileBrowser {
|
||||
Step::Stay
|
||||
}
|
||||
FileDialogRequest::Save { .. } => {
|
||||
if let AfterPick::Export { ext } = &self.after {
|
||||
// The chosen type is the format, so its extension goes on any name without it.
|
||||
let want = export_file_ext(ext);
|
||||
if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(want)) {
|
||||
path.as_mut_os_string().push(format!(".{want}"));
|
||||
}
|
||||
} else if path.extension().is_none()
|
||||
if path.extension().is_none()
|
||||
&& let Some(ext) = self.exts().first().filter(|e| e.as_str() != "*")
|
||||
{
|
||||
path.set_extension(ext);
|
||||
@@ -304,11 +273,10 @@ impl WordApp {
|
||||
self.file_browser.open = Some(Box::new(FileBrowser::new(req, after, start, unavailable)));
|
||||
}
|
||||
|
||||
/// `ui.fileBrowser`: the built-in browser for `purpose` (document, picture, recipients, save,
|
||||
/// export), even when a system dialog was asked for and never showed up (it is given up as cancelled).
|
||||
/// `ui.fileBrowser`: the built-in browser for `purpose` (document, picture, recipients, save),
|
||||
/// even when a system dialog was asked for and never showed up (it is given up as cancelled).
|
||||
pub(crate) fn browse_builtin(&mut self, purpose: &str) {
|
||||
// An export that was waiting on the system dialog keeps its format.
|
||||
let pending = self.give_up_file_dialog();
|
||||
self.give_up_file_dialog();
|
||||
if self.file_browser.open.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -319,11 +287,6 @@ impl WordApp {
|
||||
FileDialogRequest::Save { name: self.default_save_name(), dir: None },
|
||||
AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() },
|
||||
),
|
||||
"export" => {
|
||||
let ext = pending.unwrap_or_else(|| "pdf".into());
|
||||
let name = format!("{}.{}", self.title_stem(), export_file_ext(&ext));
|
||||
(FileDialogRequest::Save { name, dir: None }, AfterPick::Export { ext })
|
||||
}
|
||||
_ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }),
|
||||
};
|
||||
self.show_file_browser(req, after, false);
|
||||
@@ -508,7 +471,6 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
refilter = b.filter != before;
|
||||
});
|
||||
if refilter {
|
||||
b.type_changed();
|
||||
b.navigate(b.dir.clone());
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -769,98 +731,6 @@ mod tests {
|
||||
assert!(crate::keytips::suppressed(&a, &ctx));
|
||||
}
|
||||
|
||||
/// #3535: exporting offers File › Export's formats as the types; the chosen one is the format,
|
||||
/// the name's extension follows it, and the export writes that format.
|
||||
#[test]
|
||||
fn export_types_choose_the_format_and_the_extension() {
|
||||
let dir = scratch("export");
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("text.insert", json!({"text": "Exported in WordCraft"})).unwrap();
|
||||
a.run("ui.fileBrowser", json!({"purpose": "export"})).unwrap();
|
||||
let b = a.file_browser.open.as_mut().expect("the built-in browser is open");
|
||||
assert_eq!(b.filters.len(), EXPORT_FORMATS.len());
|
||||
assert_eq!(b.filters[b.filter].0, "PDF document (*.pdf)");
|
||||
assert!(b.name.ends_with(".pdf"), "{}", b.name);
|
||||
b.filter = b.filters.iter().position(|(l, _)| l == "Plain text (*.txt)").unwrap();
|
||||
b.type_changed();
|
||||
assert!(b.name.ends_with(".txt"), "{}", b.name);
|
||||
assert!(matches!(&b.after, AfterPick::Export { ext } if ext == "txt"));
|
||||
b.navigate(dir.clone());
|
||||
// A name typed afterwards with another extension still gets the chosen format's.
|
||||
b.name = "notes.docx".into();
|
||||
let Step::Pick(path) = b.accept() else { panic!("picked") };
|
||||
assert_eq!(path.file_name().unwrap(), "notes.docx.txt");
|
||||
a.close_file_browser(Some(path.clone()));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Exported in WordCraft"));
|
||||
// Word 2003 XML starts on its own type, though Word XML writes .xml too.
|
||||
let req = FileDialogRequest::Save { name: "x.xml".into(), dir: None };
|
||||
let b = FileBrowser::new(req, AfterPick::Export { ext: crate::print_page::EXPORT_WORD_2003.into() }, dir.clone(), false);
|
||||
assert_eq!(b.filters[b.filter].0, "Word 2003 XML Document (*.xml)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The OpenDocument template and flat-XML exports are types of their own: the name gets
|
||||
/// their extension, and the export saves by it.
|
||||
#[test]
|
||||
fn export_types_include_the_opendocument_template_and_flat_xml() {
|
||||
let dir = scratch("export_ott");
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("text.insert", json!({"text": "Template text"})).unwrap();
|
||||
for (label, ext) in [("OpenDocument Text Template (*.ott)", "ott"), ("Flat OpenDocument Text (*.fodt)", "fodt")] {
|
||||
let req = FileDialogRequest::Save { name: "x.pdf".into(), dir: None };
|
||||
let mut b = FileBrowser::new(req, AfterPick::Export { ext: "pdf".into() }, dir.clone(), false);
|
||||
b.filter = b.filters.iter().position(|(l, _)| l == label).unwrap();
|
||||
b.type_changed();
|
||||
assert_eq!(b.name, format!("x.{ext}"));
|
||||
b.navigate(dir.clone());
|
||||
let Step::Pick(path) = b.accept() else { panic!("picked") };
|
||||
assert_eq!(path, dir.join(format!("x.{ext}")));
|
||||
a.finish_pick(AfterPick::Export { ext: ext.into() }, Some(path.to_string_lossy().into_owned())).unwrap();
|
||||
assert!(path.is_file(), "{ext} written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An export waiting on a system dialog that never showed up keeps its format when Browse
|
||||
/// in WordCraft… takes over, instead of starting over on PDF.
|
||||
#[test]
|
||||
fn browse_in_wordcraft_keeps_a_pending_exports_format() {
|
||||
let services = Services {
|
||||
file_dialog: Some(Box::new(|_req| {
|
||||
let (tx, rx) = channel();
|
||||
// The system dialog never answers.
|
||||
std::mem::forget(tx);
|
||||
rx
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
a.export_dialog("rtf");
|
||||
assert!(a.file_dialog.is_some(), "waiting on the system dialog");
|
||||
a.run("ui.fileBrowser", json!({"purpose": "export"})).unwrap();
|
||||
assert!(a.file_dialog.is_none(), "the system dialog is given up");
|
||||
let b = a.file_browser.open.as_ref().expect("the built-in browser is open");
|
||||
assert!(matches!(&b.after, AfterPick::Export { ext } if ext == "rtf"));
|
||||
assert_eq!(b.filters[b.filter].0, "Rich Text Format (*.rtf)");
|
||||
assert!(b.name.ends_with(".rtf"), "{}", b.name);
|
||||
}
|
||||
|
||||
/// Word XML chosen as the export type wins over a Word 2003 XML file already at that path,
|
||||
/// whose format a save by path alone would keep.
|
||||
#[test]
|
||||
fn exporting_word_xml_over_a_word_2003_file_writes_word_xml() {
|
||||
let dir = scratch("export_xml");
|
||||
let path = dir.join("letter.xml").to_string_lossy().into_owned();
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("text.insert", json!({"text": "Dear reader"})).unwrap();
|
||||
a.finish_pick(AfterPick::Export { ext: crate::print_page::EXPORT_WORD_2003.into() }, Some(path.clone())).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("w:wordDocument"));
|
||||
a.finish_pick(AfterPick::Export { ext: "xml".into() }, Some(path.clone())).unwrap();
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains("pkg:package") && !xml.contains("w:wordDocument"), "Word XML, not Word 2003 XML");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3037: an empty folder (or a type filter that matches nothing) says so instead of a blank
|
||||
/// box; a matching file replaces the hint.
|
||||
#[test]
|
||||
|
||||
@@ -171,14 +171,12 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up;
|
||||
/// returns the format of the export it was for, if any.
|
||||
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn give_up_file_dialog(&mut self) -> Option<String> {
|
||||
let d = self.file_dialog.take()?;
|
||||
let export = if let AfterPick::Export { ext } = &d.after { Some(ext.clone()) } else { None };
|
||||
let _ = self.finish_pick(d.after, None);
|
||||
export
|
||||
pub(crate) fn give_up_file_dialog(&mut self) {
|
||||
if let Some(d) = self.file_dialog.take() {
|
||||
let _ = self.finish_pick(d.after, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host couldn't show a system dialog (#576): ask in the built-in browser instead, which
|
||||
@@ -299,9 +297,6 @@ impl WordApp {
|
||||
self.run("file.exportPng", json!({"path": path}))
|
||||
} else if ext == crate::print_page::EXPORT_WORD_2003 {
|
||||
self.run("file.saveAs", json!({"path": path, "format": ext}))
|
||||
} else if ext == "xml" {
|
||||
// Word XML even over a Word 2003 XML file, which a save by path would keep.
|
||||
self.run("file.saveAs", json!({"path": path, "format": wordcraft_engine::cmd::file::FORMAT_FLAT_OPC}))
|
||||
} else {
|
||||
self.run("file.saveAs", json!({"path": path}))
|
||||
};
|
||||
@@ -438,7 +433,8 @@ impl WordApp {
|
||||
|
||||
/// File › Export: ask where to save a copy in this format, then write it.
|
||||
pub(crate) fn export_dialog(&mut self, ext: &str) {
|
||||
let name = format!("{}.{}", self.title_stem(), crate::print_page::export_file_ext(ext));
|
||||
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
|
||||
let name = format!("{}.{file_ext}", self.title_stem());
|
||||
let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::Export { ext: ext.to_string() });
|
||||
}
|
||||
|
||||
|
||||
+15
-166
@@ -19,11 +19,6 @@
|
||||
//!
|
||||
//! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape
|
||||
//! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free.
|
||||
//!
|
||||
//! Inside a Drawing Canvas, a press on a member picks that member (`select.member`, #2637): it
|
||||
//! gets the frame and handles, inside the canvas's frame, and is dragged, resized and turned on
|
||||
//! its own; a press in a text box member's text puts the caret there. The canvas itself is
|
||||
//! picked where no member is.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Ui};
|
||||
use serde_json::json;
|
||||
@@ -133,92 +128,22 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
|
||||
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
|
||||
/// cursor and no drag.
|
||||
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
matches!(hit_object(app, hit), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
let pos = hit.pos();
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
|
||||
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
|
||||
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(
|
||||
hit_object(app, hit),
|
||||
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
|
||||
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// The object a hit is: the object at its position, or the canvas member it names.
|
||||
fn hit_object<'a>(app: &'a WordApp, hit: &ObjectHit) -> Option<&'a InlineObject> {
|
||||
let pos = hit.pos();
|
||||
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off))?;
|
||||
match (hit.member, o) {
|
||||
(None, _) => Some(o),
|
||||
(Some(i), InlineObject::Group { children, canvas: Some(_), .. }) => children.get(i).map(|c| &c.obj),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Member `index` of the canvas laid out at `canvas`, as a hit of its own: its frame where the
|
||||
/// canvas draws it, its own turn. `None` when it isn't drawn, or the canvas is turned (its
|
||||
/// members aren't picked on their own yet).
|
||||
fn member_hit(app: &WordApp, canvas: &ObjectHit, index: usize) -> Option<ObjectHit> {
|
||||
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
|
||||
let (_, frame) = o.member_rects(r.x, r.y, r.w, r.h).into_iter().find(|(i, _)| *i == index)?;
|
||||
member_with_frame(app, canvas, o, index, frame)
|
||||
}
|
||||
|
||||
/// The canvas object of hit `canvas` when its members can be picked: a canvas, hit as a whole,
|
||||
/// not turned.
|
||||
fn pickable_canvas<'a>(app: &'a WordApp, canvas: &ObjectHit) -> Option<&'a InlineObject> {
|
||||
let pos = canvas.pos();
|
||||
let o = app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).filter(|o| o.is_canvas())?;
|
||||
(canvas.member.is_none() && canvas.spin.is_identity()).then_some(o)
|
||||
}
|
||||
|
||||
/// [`member_hit`] for member `index` of canvas `o`, whose frame on the page (`[x, y, w, h]`) is
|
||||
/// already worked out.
|
||||
fn member_with_frame(app: &WordApp, canvas: &ObjectHit, o: &InlineObject, index: usize, [x, y, w, h]: [f32; 4]) -> Option<ObjectHit> {
|
||||
let InlineObject::Group { children, .. } = o else { return None };
|
||||
let member = children.get(index).filter(|c| wordcraft_engine::cmd::canvas::pickable(c))?;
|
||||
let text_box = match &member.obj {
|
||||
InlineObject::Shape { story: Some(id), .. } if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
Some(ObjectHit {
|
||||
rect: wordcraft_geom::Rect::new(x, y, w, h),
|
||||
spin: member.obj.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(),
|
||||
text_box,
|
||||
// Placed by the canvas, not the text: dragging any handle may move its top-left.
|
||||
wrap: Wrap::InFrontOfText,
|
||||
member: Some(index),
|
||||
..canvas.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// The member of the canvas hit `canvas` under the page point (x, y), topmost first; a text box
|
||||
/// member is grabbed anywhere on it too (inside, [`in_text`] tells its text from its border).
|
||||
/// The members' frames are worked out once for all of them.
|
||||
fn member_at(app: &WordApp, canvas: &ObjectHit, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
|
||||
let (o, r) = (pickable_canvas(app, canvas)?, canvas.rect);
|
||||
// Members are drawn clipped to the canvas: only what shows is grabbed.
|
||||
if !r.contains(wordcraft_geom::Point::new(x, y)) {
|
||||
return None;
|
||||
}
|
||||
o.member_rects(r.x, r.y, r.w, r.h)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|(i, frame)| member_with_frame(app, canvas, o, i, frame))
|
||||
.find(|m| m.rect.expand(edge).contains(m.unspin(x, y)))
|
||||
}
|
||||
|
||||
/// Whether the page point (x, y) is in a text box member's text rather than on its border.
|
||||
fn in_text(m: &ObjectHit, x: f32, y: f32, edge: f32) -> bool {
|
||||
m.text_box.is_some() && m.rect.w.min(m.rect.h) > edge * 3.0 && m.rect.expand(-edge).contains(m.unspin(x, y))
|
||||
}
|
||||
|
||||
/// The story of the selected text box (a picked canvas member too).
|
||||
/// The story of the selected text box.
|
||||
fn selected_text_box(app: &WordApp) -> Option<u32> {
|
||||
if let Some((_, _, InlineObject::Shape { story: Some(id), .. })) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
|
||||
return app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox).then_some(*id);
|
||||
}
|
||||
match selected(app)? {
|
||||
(_, InlineObject::Shape { story: Some(id), .. }) if app.session.doc.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox) => Some(*id),
|
||||
_ => None,
|
||||
@@ -229,11 +154,7 @@ fn selected_text_box(app: &WordApp) -> Option<u32> {
|
||||
pub fn active(app: &WordApp, layout: &DocLayout) -> Option<(ObjectHit, bool)> {
|
||||
let hint = app.session.page_hint;
|
||||
if let Some((pos, _)) = selected(app) {
|
||||
let canvas = layout.object(&pos, hint)?;
|
||||
if let Some((_, i, _)) = wordcraft_engine::cmd::canvas::selected_member(&app.session) {
|
||||
return member_hit(app, &canvas, i).or(Some(canvas)).map(|o| (o, false));
|
||||
}
|
||||
return Some((canvas, false));
|
||||
return layout.object(&pos, hint).map(|o| (o, false));
|
||||
}
|
||||
match app.session.sel.focus.story {
|
||||
StoryRef::Part(id) if crate::canvas::in_text_box(app) => layout.text_box(id, hint).map(|o| (o, true)),
|
||||
@@ -291,7 +212,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
}
|
||||
if ui.input(|i| i.pointer.primary_down()) {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move && d.object.member.is_none();
|
||||
d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move;
|
||||
if d.drag.grab == Grab::Rotate {
|
||||
d.drag.turn_to(at, pages, scale, shift);
|
||||
} else {
|
||||
@@ -309,12 +230,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
if d.drag.grab == Grab::Move && d.drag.moved && !d.object.floating() {
|
||||
// An inline one lands in the text: the insertion cursor follows the pointer.
|
||||
d.drop_at = drop_point(app, layout, pages, scale, at, &d);
|
||||
} else if d.drag.grab == Grab::Move
|
||||
&& d.drag.moved
|
||||
&& d.object.member.is_none()
|
||||
&& app.ui.alignment_guides
|
||||
&& !ui.input(|i| i.modifiers.alt)
|
||||
{
|
||||
} else if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) {
|
||||
snap_move(layout, &mut d, scale, locked_axis);
|
||||
}
|
||||
}
|
||||
@@ -363,17 +279,6 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
}
|
||||
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
|
||||
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if let Some(index) = object.member.filter(|_| pressed && !adding) {
|
||||
return press_member(app, layout, pages, scale, at, object, index, grab);
|
||||
}
|
||||
// Adding to the selection takes the whole canvas.
|
||||
let object = match object.member {
|
||||
Some(_) => match layout.object(&object.pos(), object.page) {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
},
|
||||
None => object,
|
||||
};
|
||||
// Ctrl on the object that is already selected starts a copy-drag instead, including
|
||||
// Ctrl+Shift for an aligned copy. Shift alone keeps its additive-selection behavior.
|
||||
let again = selected(app).is_some_and(|(p, _)| p == object.pos())
|
||||
@@ -434,55 +339,13 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
true
|
||||
}
|
||||
|
||||
/// A press on member `index` of a canvas (`object` is the member's hit): in a text box member's
|
||||
/// text it puts the caret there; elsewhere it picks the member (selecting its canvas first) and
|
||||
/// starts dragging it, or the handle `grab`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn press_member(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, object: ObjectHit, index: usize, grab: Grab) -> bool {
|
||||
let edge = EDGE / scale.max(0.01);
|
||||
if grab == Grab::Move
|
||||
&& let (Some(id), Some((page, x, y))) = (object.text_box, crate::canvas::page_at(pages, layout, scale, at))
|
||||
&& in_text(&object, x, y, edge)
|
||||
{
|
||||
// A box linked from another shows (and edits) that box's story.
|
||||
let id = app.session.doc.text_box_chain(id).and_then(|c| c.first().copied()).unwrap_or(id);
|
||||
if let Some(pos) = layout.hit(page, x, y, StoryRef::Part(id)) {
|
||||
let _ = app.run("caret.set", json!({"pos": pos}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let pos = object.pos();
|
||||
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
|
||||
let _ = app.run("select.range", json!({"anchor": pos, "focus": end}));
|
||||
if app.run("select.member", json!({"index": index})).is_err() {
|
||||
return true;
|
||||
}
|
||||
let Some(o) = hit_object(app, &object) else { return true };
|
||||
let (min, picture) = (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. }));
|
||||
app.canvas.obj_drag = Some(ObjectDrag {
|
||||
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
|
||||
press_origin: pages.get(object.page).map_or(at, |p| p.min),
|
||||
object,
|
||||
min,
|
||||
picture,
|
||||
pending_behind: false,
|
||||
end: None,
|
||||
guides: Vec::new(),
|
||||
preview: None,
|
||||
copy: false,
|
||||
drop_at: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The object a move has taken off its page (which page, and where it is in the document): the
|
||||
/// page is rendered again without it while the preview follows the pointer, so the object is not
|
||||
/// left behind where it was. Only for a move — resizing and turning show the object itself, and
|
||||
/// dragging a connector's end onto a shape still shows that connector. Nor for a member of a
|
||||
/// drawing canvas: its position is the canvas's, and the canvas stays.
|
||||
/// dragging a connector's end onto a shape still shows that connector.
|
||||
pub(crate) fn dragged_off_page(drag: Option<&ObjectDrag>) -> Option<(usize, Pos)> {
|
||||
let d = drag?;
|
||||
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move || d.object.member.is_some() {
|
||||
if !d.drag.moved || d.end.is_some() || d.drag.grab != Grab::Move {
|
||||
return None;
|
||||
}
|
||||
Some((d.object.page, d.object.pos()))
|
||||
@@ -617,18 +480,15 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
|
||||
return selected_picture;
|
||||
}
|
||||
// Selectable: body objects (table cells too) or the header/footer's own objects;
|
||||
// notes' objects aren't yet. Inside a canvas, the member there.
|
||||
let edge = EDGE / scale.max(0.01);
|
||||
let o = layout.object_at_in(page, x, y, edge, story)?;
|
||||
Some((member_at(app, &o, x, y, edge).unwrap_or(o), Grab::Move))
|
||||
// notes' objects aren't yet.
|
||||
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
|
||||
/// header/footer.
|
||||
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
|
||||
// A canvas member moves inside its canvas, wherever the canvas is.
|
||||
(o.member.is_some() || o.path.depth() == 0) && selectable_story(app, o.story)
|
||||
o.path.depth() == 0 && selectable_story(app, o.story)
|
||||
}
|
||||
|
||||
/// Whether a Behind Text object of this kind is grabbed by pressing its body. Pictures, shapes and
|
||||
@@ -681,7 +541,7 @@ fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
|
||||
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
|
||||
/// per page point).
|
||||
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
|
||||
let obj = hit_object(app, o)?;
|
||||
let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?;
|
||||
let page = layout.pages.get(o.page)?;
|
||||
let mut opts = crate::canvas::screen_render_options();
|
||||
opts.dark = crate::canvas::dark_page(app);
|
||||
@@ -808,13 +668,6 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
if let Some((o, editing)) = active(app, layout)
|
||||
&& let Some(f) = screen(pages, scale, o.page, o.rect)
|
||||
{
|
||||
// A picked canvas member: the canvas's frame around it, without handles.
|
||||
if o.member.is_some()
|
||||
&& let Some(c) = layout.object(&o.pos(), o.page)
|
||||
&& let Some(cf) = screen(pages, scale, c.page, c.rect)
|
||||
{
|
||||
frame::paint(painter, t, cf, c.spin.deg, false, false, false);
|
||||
}
|
||||
// Handles where it can be resized and turned (body and header/footer objects,
|
||||
// not charts): else a plain frame.
|
||||
let handles = selectable_story(app, o.story) && !fixed(app, &o);
|
||||
@@ -887,11 +740,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
|
||||
if connector_end_at(app, layout, pages, scale, at).is_some() {
|
||||
return Some(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
let in_member_text = |o: &ObjectHit| {
|
||||
crate::canvas::page_at(pages, layout, scale, at).is_some_and(|(_, x, y)| o.member.is_some() && in_text(o, x, y, EDGE / scale.max(0.01)))
|
||||
};
|
||||
grab_at(app, layout, pages, scale, at)
|
||||
.map(|(o, g)| if g == Grab::Move && !fixed(app, &o) && in_member_text(&o) { egui::CursorIcon::Text } else { hover_cursor(app, &o, g) })
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
|
||||
}
|
||||
|
||||
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
|
||||
|
||||
@@ -1010,90 +1010,3 @@ fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
|
||||
let [width, height] = preview.size();
|
||||
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
|
||||
}
|
||||
|
||||
/// #2637: inside a Drawing Canvas the mouse works on the members, as in Word. A click on a
|
||||
/// shape picks that shape (the canvas stays the selection in the text) and shows its frame;
|
||||
/// dragging it moves it inside the canvas; a click in a text box member's text puts the
|
||||
/// caret there. Before, the canvas took every click, as one static object.
|
||||
#[test]
|
||||
fn clicking_and_dragging_inside_a_canvas_works_on_its_members() {
|
||||
use egui::{Event, PointerButton};
|
||||
use wordcraft_engine::cmd::canvas::selected_member;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
a.run("insert.canvas", json!({"width": 400, "height": 300})).unwrap();
|
||||
a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
|
||||
a.run("canvas.member", json!({"index": 0, "x": 40, "y": 30})).unwrap();
|
||||
a.run("insert.textBox", json!({"text": "Label"})).unwrap();
|
||||
a.run("select.objects", json!({"index": 0})).unwrap();
|
||||
a.run("canvas.member", json!({"index": 1, "x": 200, "y": 100, "width": 150, "height": 80})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let frame = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("the canvas is laid out").rect;
|
||||
let at = |h: &mut egui_kittest::Harness<'_, WordApp>, x: f32, y: f32| {
|
||||
crate::canvas::page_to_screen(h.state_mut(), 0, frame.x + x, frame.y + y).unwrap()
|
||||
};
|
||||
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let members =
|
||||
|h: &egui_kittest::Harness<'_, WordApp>| match h.state().session.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()) {
|
||||
Some(InlineObject::Group { children, .. }) => children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
|
||||
// A click on the rectangle picks it.
|
||||
let on_rect = at(&mut h, 90.0, 50.0);
|
||||
h.event(Event::PointerMoved(on_rect));
|
||||
h.event(button(on_rect, true));
|
||||
h.step();
|
||||
h.event(button(on_rect, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(selected_member(&h.state().session).map(|(_, i, _)| i), Some(0), "the rectangle is picked, not the whole canvas");
|
||||
let shown = {
|
||||
let app = h.state_mut();
|
||||
let layout = app.session.layout();
|
||||
active(app, &layout).map(|(o, _)| (o.member, o.rect))
|
||||
};
|
||||
assert_eq!(shown, Some((Some(0), wordcraft_geom::Rect::new(frame.x + 40.0, frame.y + 30.0, 100.0, 40.0))), "its frame is the one shown");
|
||||
|
||||
// Dragged 50 pt right and 20 pt down, it moves inside the canvas; the canvas stays put.
|
||||
let to = at(&mut h, 140.0, 70.0);
|
||||
h.event(Event::PointerMoved(on_rect));
|
||||
h.event(button(on_rect, true));
|
||||
h.step();
|
||||
for p in [on_rect + egui::vec2(0.0, 30.0), to + egui::vec2(0.0, 30.0), to] {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
h.event(button(to, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let moved = members(&h);
|
||||
let near = |(x, y): (f32, f32), (ex, ey): (f32, f32)| (x - ex).abs() < 0.01 && (y - ey).abs() < 0.01;
|
||||
assert!(near(moved[0], (90.0, 50.0)) && near(moved[1], (200.0, 100.0)), "{moved:?}");
|
||||
assert_eq!(h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| o.rect), Some(frame));
|
||||
|
||||
// A click in the text box's text puts the caret in it.
|
||||
let in_box = at(&mut h, 275.0, 140.0);
|
||||
h.event(Event::PointerMoved(in_box));
|
||||
h.event(button(in_box, true));
|
||||
h.step();
|
||||
h.event(button(in_box, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let story = h.state().session.sel.focus.story;
|
||||
let is_box = matches!(story, StoryRef::Part(id) if h.state().session.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox));
|
||||
assert!(is_box, "the caret is in the text box's text: {story:?}");
|
||||
}
|
||||
|
||||
@@ -454,44 +454,28 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout) {
|
||||
/// The export "format" of a Word 2003 XML Document: an `.xml` file saved with `format: "wordml2003"`.
|
||||
pub(crate) const EXPORT_WORD_2003: &str = "wordml2003";
|
||||
|
||||
/// The "save a copy" formats as (label, format), also the built-in browser's types when exporting.
|
||||
pub(crate) const EXPORT_FORMATS: &[(&str, &str)] = &[
|
||||
("PDF document (*.pdf)", "pdf"),
|
||||
("Word document (*.docx)", "docx"),
|
||||
("Word XML Document (*.xml)", "xml"),
|
||||
("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("OpenDocument Text Template (*.ott)", "ott"),
|
||||
("Flat OpenDocument Text (*.fodt)", "fodt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
("Markdown (*.md)", "md"),
|
||||
("LaTeX (*.tex)", "tex"),
|
||||
("Plain text (*.txt)", "txt"),
|
||||
("Page image (*.png)", "png"),
|
||||
];
|
||||
|
||||
/// The file extension an export format writes.
|
||||
pub(crate) fn export_file_ext(format: &str) -> &str {
|
||||
if format == EXPORT_WORD_2003 { "xml" } else { format }
|
||||
}
|
||||
|
||||
/// The "save a copy" formats (File › Export, and below the settings on the Print page).
|
||||
pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
|
||||
for &(label, ext) in EXPORT_FORMATS {
|
||||
for (label, ext) in [
|
||||
("PDF document (*.pdf)", "pdf"),
|
||||
("Word document (*.docx)", "docx"),
|
||||
("Word XML Document (*.xml)", "xml"),
|
||||
("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003),
|
||||
("OpenDocument Text (*.odt)", "odt"),
|
||||
("OpenDocument Text Template (*.ott)", "ott"),
|
||||
("Flat OpenDocument Text (*.fodt)", "fodt"),
|
||||
("Rich Text Format (*.rtf)", "rtf"),
|
||||
("Web page (*.html)", "html"),
|
||||
("Markdown (*.md)", "md"),
|
||||
("LaTeX (*.tex)", "tex"),
|
||||
("Plain text (*.txt)", "txt"),
|
||||
("Page image (*.png)", "png"),
|
||||
] {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
|
||||
app.export_dialog(ext);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
// Linux and the BSDs (#3535): WordCraft's own browser when the system dialog doesn't appear.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
{
|
||||
ui.add_space(8.0);
|
||||
if ui.add(egui::Button::new(egui::RichText::new(tl!("Browse in WordCraft…")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
|
||||
let _ = app.run("ui.fileBrowser", json!({"purpose": "export"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -54,8 +54,8 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"\|"export"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576; `export` offers File › Export's formats as its types, #3535), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"\|"export"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576; `export` offers File › Export's formats as its types, #3535), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
|
||||
+3
-7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-5
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
|
||||
# the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: 1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user