Commit Graph
7 Commits
Author SHA1 Message Date
a30a1866e2 Web: keep preferences such as dark mode between visits (#2132) (#2205)
The web shell never loaded or saved the UiState the desktop app keeps in
ui.json, so a reload reset the theme and every other preference. It now
restores it from localStorage at startup and writes it back whenever it
changes (a reload runs no exit code).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:03:16 -04:00
c08d9f8598 Accessibility Options: Animation effects setting that follows the system (#791)
* Options: Animation effects setting that follows the system

The app eased wheel scrolling, Ctrl+wheel zoom and its fades whatever the
system's animation setting was: with Windows' Animation effects off, the
page still smooth-scrolled.

File > Options > Accessibility > Animation effects: Use system setting
(the default), On or Off; `ui.animations` for agents; kept in ui.json.

- Off: a wheel notch scrolls the page its whole distance at once, egui's
  own scroll areas get the notch whole instead of eased in, Ctrl+wheel
  zoom steps at once, and egui's fades and scroll-to glides are off.
  Touchpad momentum and the caret blink are unchanged.
- Use system setting asks the host (`Services::animations_enabled`) at
  start-up and when the window regains focus. Windows: the Windows
  Runtime's UISettings, a safe API of the `windows` crate already in the
  build. Web: `prefers-reduced-motion`. macOS and Linux/BSD aren't read
  yet (docs/gaps.md); there it means on.
- The eight new strings are in all eleven interface catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Animation effects off: zoom steps land finished in one frame

Follow-up to the Animation effects setting: zooming could still look
animated with the setting off.

- Ctrl+wheel zooms in whole steps, each at once. A mouse-wheel notch is
  two steps in its frame, the same zoom as before. Turns that arrive in
  small pieces (a touchpad pinch that Windows reports as Ctrl+wheel, a
  high-resolution or free-spinning wheel) add up to a step of about 10%
  first, where each piece used to zoom a little and the zoom glided.
- A page re-rendered for a zoom step is drawn on the pixel grid in that
  frame. It used to be drawn off the grid, slightly soft, and snap sharp
  a frame later.
- The frame a zoom changes in asks for the next one at once, so the
  status bar's percentage and slider follow in the next frame. They
  could lag until the caret's next blink, up to half a second.

The last two apply whatever the setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Animation effects: follow the system's setting on macOS, Linux and the BSDs

"Use system setting" only read Windows and the browser; elsewhere it
meant on.

- macOS: System Settings > Accessibility > Display > Reduce motion,
  through NSWorkspace (a safe call in objc2-app-kit 0.3, already in the
  build).
- Linux and the BSDs: the XDG settings portal over D-Bus (zbus, already
  in the build through AccessKit). Three settings say whether animations
  play, and any one saying no turns them off: the cross-desktop
  reduced-motion preference, GNOME's enable-animations and KDE Plasma's
  animation speed. A thread reads them at start-up and follows the
  portal's change signal. Where no portal answers, or it knows none of
  them, KDE's kdeglobals and GTK's settings.ini are read instead.
- The UI now asks the host every frame instead of at start-up and on
  focus, so a change in the system's settings is followed while the app
  runs. Hosts answer from memory: Windows and macOS are asked again at
  most once a second, the web keeps its media query, and Linux/BSD is
  kept current by its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Animation setting on Linux: ask every setting again when one changes

KDE's portal announces a new animation speed only as the cross-desktop
reduced-motion preference, not under its own kdeglobals name. Keeping
each setting's last announced value left KDE's own one stale: after
going from instant back to normal speed, animations stayed off.

Every setting is now read afresh on any relevant change, and the
settings files' answer stands whenever the portal has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Caret glide follows Animation effects; its canvas test turns the setting off, then on

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:24:58 -04:00
1b96e04b2c Add fonts at run time: Add Font…, drop a font file, font.add (#582) (#668)
* Add fonts at run time: Add Font…, drop a font file, font.add (#582)

Rebased onto main as one commit (the branch had merge commits from main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #582: font test no longer depends on the system's fallback font

The FreeBSD runner has no system fonts, so a missing family fell back to
Source Serif 4, the font the test compared against. The test now adds a
renamed JetBrains Mono under a name without Serif/Sans/Mono (which picks a
fallback of that kind), with narrow-letter sample text: monospaced, it
measures unlike any proportional fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:38:20 -04:00
11a6ad60a5 Paste pictures from the system clipboard (screenshots, Copy Image, copied files) (#45) (#272)
egui's paste event only carries text, so a clipboard holding just a picture
sent no paste at all on Ctrl+V, and the ribbon/context-menu Paste only knew
WordCraft's own copy ("the clipboard is empty").

Desktop: the app reads the clipboard's picture through a new
Services::clipboard_picture (arboard, already in the build via egui-winit):
copied files first, else image pixels. Mod+V whose release arrives without a
paste event, and Paste from the ribbon or menus, insert that picture through
insert.picture (one undo step); text naming copied picture files inserts the
pictures. Pixel sizes are hostile: at most 16,000 px a side and 50 Mpx, the
buffer must match, files at most 200 MB.

Web: a document paste listener reads image files from the paste event when
there's no text and delivers them through the inbox like dropped pictures.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 14:54:28 -04:00
85da7e40e0 web: print straight to the system print dialog, not just export (#209)
* web: print straight to the system print dialog, not just export

The web build's Print backstage page only let you save a copy in another
format — there was no way to actually send the document to a printer short
of exporting a PDF and opening it yourself. Add a dedicated Print button
that exports to PDF in memory and opens it in a hidden iframe, then calls
the iframe's own print() once it has loaded, which is the standard way to
drive the browser's native print dialog on a PDF without a download or a
popup window.

- ui-egui: new Services::print hook (web: hand PDF bytes to the browser's
  print flow directly, no download).
- ui-egui backstage: "Print" button on the Print page when the host
  implements the hook; goes through wordcraft_engine::io::save_bytes
  directly rather than the existing save/download command path, since we
  want raw bytes in memory, not a file write.
- wordcraft-web: implement the hook (Blob -> object URL -> hidden iframe ->
  iframe.contentWindow.print() on load).

Desktop builds are unaffected (no Services::print set there, so the
button doesn't appear - file.print still opens the Print page as before).

* web print: make printing the ui.print command, dispatched by id

The backstage Print button now runs `ui.print` instead of exporting and
calling the host hook inline, so the control channel, MCP bridge and command
search reach the same path. Like ui.openFileDialog it is the one explicit
call that opens system UI; without a print hook it is an error. Status
strings get rows in all six catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* web print: drop the previous print's iframe and object URL

Each Print appended a hidden iframe holding a fresh blob URL of the whole
document's PDF and never released it, so copies piled up in the page. The
frame now has a fixed id; the next print revokes its URL and removes it
first, and a failure part-way through cleans up after itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* web print: test that Print needs the host's print hook

ui.print errors without Services::print and hands the hook a PDF with it;
a failing hook reports why. The Print page shows the Print button only when
the hook is set, and the button prints through the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:40:16 -04:00
802ba30441 Desktop and web: ask to save changes before New, Open or Close; AutoSave only files you saved (#151)
* Desktop and web: ask to save changes before New, Open, Close or quit

New (Mod+N, File › New), Open (once a file is picked, recent files,
dropped files), Close (Mod+W) and the window's close button replaced or
closed a document with unsaved changes without asking, and its undo
history went with it: `Session::set_document` drops both, and nothing
checked `dirty` first. Closing or reloading the browser tab lost the
document the same way.

`WordApp::run`, the path every user action takes, now stops New, Open
with a path and Close on a dirty document and shows "Do you want to save
changes to <name>?" with Save / Don't Save / Cancel; the command runs
once it is answered (`ui.saveChanges`, so agents can answer it too).
Save goes through the normal save, or Save As for a document without a
file, and a failed or cancelled save cancels. The window's close button
is held with `ViewportCommand::CancelClose` until the prompt is
answered. Scripts and agents keep the old behaviour: the control channel
(and the MCP bridge over it) now calls `WordApp::execute`, which never
asks, as programmatic calls never open dialogs.

While the prompt is up, keys go to it rather than to the page behind
(Enter would otherwise type a paragraph mark into the document being
saved). On the web, a `beforeunload` listener asks the browser to
confirm leaving with unsaved changes, and Export (PDF/PNG) no longer
marks the document saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AutoSave: only write to a file the user saved to, and report failures

AutoSave was on for every document with a path, and `file.open` sets
the path, so opening contract.docx and typing one character rewrote it
in place about 2.5 s later. A save drops whatever the readers don't
model (content controls, charts, SmartArt, macros…), so that content was
deleted from the original without the user ever pressing Save, and
undo can't bring it back. Save errors were discarded with `let _ =` and
retried every 2.5 s.

AutoSave now writes only to the file the document was explicitly saved
to (Save / Save As) in this session; a file that was only opened is left
alone until the user saves it. New and Open reset that. A failed
AutoSave is shown in the status bar and logged, and turns AutoSave off
for that file until the next explicit save instead of retrying forever.

The title-bar toggle shows on only when AutoSave actually covers the
document; clicking it on an unsaved or merely opened document starts
Save As, as it already did for a new one. The Options label now says
which documents AutoSave covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: Enter clicks the focused button, not always Save

With Cancel or Don't Save focused (Tab), egui turns Enter into a click on
that button, but the dialog then also checked `key_pressed(Enter)` on
its own and overwrote the answer with Save. Cancelling a Close from the
keyboard therefore saved the file over itself and quit, and Don't Save
saved too.

The answer now comes from the button that was clicked; Enter means Save
only when none of the three buttons has focus (the usual case, with the
page behind still holding focus). Escape still cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: forget it when its document is replaced

A prompt asked about document A kept its pending action after a script
or agent (control channel, MCP) opened document B. Don't Save then ran
the retained New or Open against B and threw away B's edits, under a
question that named A.

`Session` now has a `document_id` that changes only when `set_document`
replaces the document (open, new, mail merge, recover), never on an
edit. The prompt records the id it asked about. Any command that
replaces the document drops a pending prompt, and an answer that arrives
anyway (a click in the same frame, `ui.saveChanges` from an agent) is
refused when the id no longer matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AutoSave: keep it on when the Open picker is cancelled

`file.open` without a path only asks the UI for the file picker, and it
returns Ok. The AutoSave bookkeeping cleared the saved-file permission
on any successful `file.open`, so pressing Mod+O and cancelling turned
AutoSave off for a document that never changed, until the next explicit
save.

The permission is now cleared when the document is actually replaced,
which `Session::document_id` tells apart from an edit. That also covers
the other commands that replace it (mail merge, recover), which the
id-based check missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web: tell the leave-page guard about edits made while drawing

The `beforeunload` guard reads a flag the web shell copied from
`session.dirty` after `logic`. Typing and formatting happen later in
the same frame, in `ui`, so after the first keystroke in a clean
document the flag still said "saved" until the next frame's `logic`.
Closing or reloading the tab in that window left without asking.

The app now reports its unsaved state to the host after both passes
(`Services::on_dirty`, called at the end of `logic` and of `ui`), and
the web shell sets the guard's flag from that instead of copying it
itself. That puts the timing in `ui-egui`, where a native test can
cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: a save that drops content doesn't count as saved

The prompt's Save accepted any `saved: true`. Save As to a format that
doesn't keep the whole document (a .png of page 1, .txt, .md) reports
that while leaving the document unsaved, so New, Open or Close went
ahead and the document, with its undo history, was gone.

Save now lets the command continue only when the document is clean
afterwards, i.e. it went to a format `file.save` treats as the
document's own file (docx, odt, rtf, json). Otherwise the command is
cancelled, the document stays open, and the status bar says to save it
as .docx. A document opened from a .txt file therefore needs Save As
.docx (or Don't Save) before it can be replaced: the engine already
treats a text save as a copy.

The web shell's download path cleared the unsaved flag for any save,
whatever the format, so a .png or .txt "save" also silenced the prompt
and the leave-page guard. It now clears it only for those formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web: a failed download is not a save

The download service returned nothing, and the web shell caught and
only logged a failure to start the download. Save therefore always
cleared the unsaved flag and reported `downloaded`, so the Save Changes
prompt went ahead with New or Open (replacing the document and its
undo history) even when nothing was downloaded, and the leave-page
guard stopped asking.

`Services::download` now returns a Result and the web shell passes its
error through. On an error, Save leaves the document unsaved, shows the
failure in the status bar and returns it, and the prompt cancels the
pending command.

Limit: a browser doesn't tell the page what happens after the download
starts (the user may cancel it or the file may be blocked), so a
download that started is still treated as saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-i18n: Save Changes prompt and AutoSave strings in pt-br, es and uk

The renamed AutoSave option, its failure message, the Save Changes prompt and
the failed-download message had rows only in ja/zh catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes and AutoSave: .docm, .dotx and .dotm keep everything (#172)

Since #172 these formats keep macros and content types, and file.save already
treats them as the document's own file. keeps_everything() and AutoSave now
share one list that includes them, so a web download of a .docm marks the
document saved and AutoSave covers a .docm/.dotx/.dotm saved this session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save As: keep main's suggested name (.docm stays .docm) after the merge

The merge kept the PR's save_as_dialog body, which reverted main's
default_save_name() suggestion back to <title>.docx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:25:45 -04:00
Brandon ThomasandClaude Opus 5.5 d1a0ed39c0 M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:38:37 -04:00