92 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 e22f276c1d R6.14 done: 0.1.5 rebuilt on aa419fd and uploaded
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 21:26:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 aa419fdfc9 R6.13 round 3 fix: each wheel notch turns; drop the unused part()
release / macos (push) Failing after 1s
release / windows (push) Successful in 12m14s
release / linux (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:43:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 407ce00ab9 R6.13 round 2 fix: the worker turns in input order; scrolls by gesture
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:35:43 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d427d05b96 R6.13 round 1 fix: notches per platform, the worker decides arrows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:22:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c2897aa21a R6.12 done: review round 3 passed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:03:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f72528c474 R6.12 round 2 fix: unbundled workers ask for accessory
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 19:56:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1317c4ad39 R6.12 round 1 fix: the worker registers as a UI element
The worker set the accessory policy after AppKit had already registered it
as a Dock app, so 11 of 15 launches showed Foreground for about 30 ms.
Now it sets LSUIElement in its main bundle's info before the kernel starts
AppKit, and fails to start unless the policy came up as accessory.

tests/scripts/dock-macos.sh samples each worker's type from its start until
the first tile; on the Mac mini 30 launches were all UIElement, and the
shipped worker fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 19:45:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fdb8317d24 R6.12, R6.13: the LOK worker has no Dock icon; Show turns slides
R6.12: on macOS the worker sets LibreOffice's own NSApplication to the
accessory policy before the kit starts, so it never shows in the Dock.

R6.13: when the whole slide fits, the wheel and Up/Down (nothing
selected, no text editing) turn slides in the ElyShow editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 19:29:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bb943fb84b R6.5, R6.10 done: 0.1.5 rebuilt on 53630ce and uploaded
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 16:55:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 53630ce0be R6.11 round 3 fix: numbered backups, checked install list
release / macos (push) Failing after 1s
release / windows (push) Successful in 11m29s
release / linux (push) Successful in 7m29s
Backups under .ely-old are numbered in list order, so paths near the
Windows limit still upgrade and uninstall. Uninstall checks the list
file first and puts everything back if it cannot delete it. Five
comments shortened. Round 3 was the last; ticked without review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:32:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cb47f0295f R6.11: review verdicts, rounds 1 and 2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:03 -04:00
ZacharyZhang-NYandClaude Opus 5.5 50d348d4e5 R6.11 round 2 fix: the installer lists every file it installs
The list names each file and folder; upgrades and uninstall move only
listed files and remove a folder only when empty. Every file, nested
ones included, must be writable first. A new file whose place holds a
file this app did not install refuses the install. A copy failing
midway removes what it wrote and puts the old release back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:18:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8946a08538 Video scripts: comments are one line, ten words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 615d2f9992 R6.11: files.sh header is one line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 23735aee3c R6.11 round 1 fix: the installer removes only what it installed
Reinstalling deleted files Ely never installed, and a busy file left a
half-removed install. The installer now writes the top-level names it
installs (.ely-files, from packaging/windows/files.sh); upgrades and
uninstall remove only those, and releases before the list only in the
folder the registry names. It checks that every listed file can be
written, moves them all into .ely-old, and on any refusal puts them
back and exits 2. A file that cannot be written fails the install,
which removes what it created and restores the old release.
tests/scripts/windows-installer.sh covers each case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:59:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5dafa432f6 R6.11: the Windows installer removes an older install's files first
Upgrades kept ElyMark's old git folder and CEF files. The installer
now runs the uninstaller's file list before copying, only when the
folder already holds this app's exe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:44:03 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a0269294e6 R6.9 done: review round 2 passed
release / windows (push) Failing after 2s
release / linux (push) Skipped
release / macos (push) Failing after 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 15:08:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 157c8985b6 R6.9 round 1 fix: strong keeps a heading's weight; sizes are tested
Component 90bd37b.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:57:19 -04:00
ZacharyZhang-NYandClaude Opus 5.5 de586da545 R6.8 done: review round 3 passed
Component acbbe1d shortens one R6.9 test comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:33:37 -04:00
ZacharyZhang-NYandClaude Opus 5.5 effe92a80a R6.8 round 2 fix: two comments fit ten words
Component 4a8f940.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:20:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d1c1ce97cd R6.8 round 1 fix: measure with the current text; the line end is a stop
Component cd61653. Round 1 verdict kept in tasks/reviews.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 14:03:54 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1e3f50ebc1 R6.7 done: review round 3 passed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 13:37:54 -04:00
ZacharyZhang-NYandClaude Opus 5.5 16a6390eeb R6.7 round 2 fix: a kept CRLF reads back char for char
Pins component 26af4be.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 13:28:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 afe3ade415 R6.7 round 1 fix: a drawn CRLF reads back to its start
Pins component c31d092.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 13:05:54 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fea441cc39 R6.6 done: review round 3 passed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:49:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a316a5481a R6.6 round 2 fix: Git lookup tests use only temp folders
The lookup is split from the developer fallback, so the tests
need no built Git and check NoGit explicitly. Shorter stage.sh comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:37:20 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5214eddf77 R6.9: ElyMark headings step up from the body, all bold
Component 5954132 sizes headings by Typora's steps over the body.
Checked in a real Windows window with R6.7 and R6.8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:30:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8375861903 R6.8: source mode places the caret where the line is laid out
Component 9fb607b measures the drawn line, so a caret after a CJK
character sits after it, not mid-glyph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:22:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8580aed6b0 R6.6 round 1 fix: no Git says to install ElyTree; Linux keeps Mark's Git
git::own returns git::NoGit when neither the app nor ElyTree has Git;
ElyMark's Git item then reads 'needs ElyTree'. Linux AppImages stand
alone, so ElyMark keeps its Git there (packages share the AppDir).
The bundle fixture now has ElyTree's executable folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:21:13 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5958e73ade R6.7: ElyMark keeps a single line break on screen
Component 93efa7f draws soft breaks as lines (Typora's default when
writing); exports and copies read with MarkdownReader::joined, as GFM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:10:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0a5d7ea6dd R6.6: ElyMark uses ElyTree's Git on every platform
Windows and Linux no longer stage Git or its licenses for ElyMark.
git::own looks beside the app, then beside ElyTree (sibling install
or macOS registration). R6.5 review round 1 passed; R6.6-R6.10 listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:02:07 -04:00
ZacharyZhang-NYandClaude Opus 5.5 eb606e961d R6.5: sign the LOK worker with allow-jit under hardened runtime
0.1.5's Developer ID signing turned on the hardened runtime. UNO's
bridge maps its vtables with MAP_JIT, which needs allow-jit; without
it the worker aborts on its first request, so ElyDoc, ElyTable and
ElyShow could not start on macOS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 10:56:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8f31e53cfc 0.1.5 update video and Chinese changelog
Real footage of the installed 0.1.5 apps, composited in Remotion with
shape morphs between sections; synthesized score and UI sounds. Logs
P3-17 (ElyTable keyboard zoom leaves the grid half-scrolled).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 09:24:06 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2e1ba5eded R6.4 done: 0.1.5 re-released from 39ff567 and installed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 03:57:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 39ff567226 R6.3 done: round 3 fix, hard links join the recent list
release / macos (push) Failing after 1s
release / windows (push) Failing after 2s
release / linux (push) Skipped
An explicit open checks the session registry by file identity, so a
hard link to a shown file joins the list. Ticked after round 3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 03:36:58 -04:00
ZacharyZhang-NYandClaude Opus 5.5 030ecfcff6 R6.3 round 2 fix: an explicit open of a shown file joins the list
Opening a file a tab already shows only focuses it; the recent list
now learns it too. The restart regression reopens the restored file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 03:27:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1abaa5dc83 R6.3 round 1 fix: restored tabs keep the recent list cleared
A tab reopened from the session counts as already remembered, so
clearing the list survives a restart; explicit opens still join it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 03:18:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9b71389a84 R6.2 done: round 2 passes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 03:03:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5760d44956 R6.2 round 1 fixes: component 5a53c08
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:56:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f7163d10e3 R6.2: cascading menus with hover intent (component d4fdb69)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:33:08 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e01e9a4cb5 R6.1 round 3 fixes, ticked: component f1771b3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:12:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b973c8d62a R6.1 round 2 fixes: component 2b6bd1d
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:41:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 02b86cd5cd R6.1 round 1 fixes: component 6a3f348
Open cells keep presses inside their field, finds open the matching
cell, a closing backslash keeps the next pipe, and right presses map
through typed pipes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 01:18:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 587862ed4d ElyMark: a press in a table opens its cell alone (component 2594b3f)
Visual mode opened a pressed table whole, as Markdown source. The
component now opens only the pressed cell, in place, as its source;
Tab, Shift-Tab and the arrows walk the cells, Enter and Escape write
back in one undo step, a typed bare pipe is escaped. Adds the R6
checklist for the 0.1.5 re-release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:53:12 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0dea1fabb4 R5.8: 0.1.5 released on Gitea; log duplicate source archives (P3-16)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 23:19:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 98692cb80f ElyMark: clear the recent list from the sidebar
A Clear Recent Files button under the list writes an empty recent.json
(ely_files::recent::clear); a failure is shown, not swallowed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 23:01:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 15d4c2066d Version 0.1.5
release / windows (push) Failing after 5s
release / linux (push) Skipped
release / macos (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:58:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f18f3adbec R4.11: close as [~] after round 3
All three findings were in the ignored acceptance driver's backup and
restore, not in product code; each is fixed with a regression.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:57:21 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fef5735267 R4.11: round 2 verdict
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:49:13 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fa02f1a323 R4.11: round 1 verdict
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:41:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 4460fd35a6 R4.11: real-window acceptance; shifted keys bind their characters
A real keyboard reports Shift plus punctuation as the character on every
platform, so the Windows key-name bindings never fired. Windows also
keeps Ctrl+Shift+0 for Chinese input; Actual Size adds Ctrl+Shift+9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:30:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 65514599fe ElyMark: R4.10 round 3 fixes; R4.10 marked [~]
The copy guard also compares the clipboard's HTML, which gpui omits,
so a later rich copy with the same plain text is not overwritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 22:16:08 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2c374b23ed ElyMark: R4.10 round 2 fixes (copy guard, component 2b8bd9f)
A pending conversion no longer overwrites a later clipboard write; pin
moves to component 2b8bd9f (one-pass drawn lookup, latest press wins).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:52:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 67fa5bfc94 R4.10 round 1 fixes: background copies, menu offer served once
Copy as Rich Text, HTML Code and Plain Text convert off the window
thread; a newer copy cancels an older one. Offer hands its request to
the menu host once, so a dismissed menu stays shut when its tab returns.
ElyMark Delete Image now targets the pressed picture (component a4bf696).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:17:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5462b8fb0e R4.13: round 2 passes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:07:07 -04:00
ZacharyZhang-NYandClaude Opus 5.5 45ac9c6ec4 R4.13: round 1 fixes; app language leads Han layout
- ely-ui passes its resolved locale to Windows text layout, so
  Chinese UI lays out as Chinese whatever the Windows region.
- Component a4bf696: Find box, sort and filter builders and
  password tooltips read the catalog; stricter shipped-copy scan.
- ElyMark deletes the right-clicked picture (delete_image(at)).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 20:52:17 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b714024d2b Release: sign and notarize macOS builds from repository variables
The macOS job reads ELY_SIGN_IDENTITY and ELY_NOTARY_PROFILE from the
repository's Actions variables; ci.sh fails on macOS when either is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 20:43:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 41f3af04cf R4.13: Chinese displays in one font, shipped components in Chinese
Windows text layout takes the user's first Han language when the
locale is not Han, so simplified characters no longer fall back to
Japanese fonts. Component pin 6df9ab0 routes the remaining English
tooltips, placeholders and samples through i18n.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 20:05:37 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9c469c4098 R4.12: round 2 passes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:40:46 -04:00
ZacharyZhang-NYandClaude Opus 5.5 78a285d124 R4.12: answer the token before paths, one deadline per caller, hand-overs via the main window
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:30:13 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5bf2056281 All apps: a second launch hands its files to the running copy
R4.12 (user report). The copy holding the instance lock opens a local
door guarded by a random token in its data folder; a second copy sends
its file paths there, lets it take the foreground, and exits 0 instead
of showing "already running". Files join the ely_ui::on_open queue every
app already uses; the main window comes to the front. Also records the
R4.10 round 1 verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:02:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d5ceaf9aac ElyMark: right-click menu by target; Copy as HTML Code and Plain Text
R4.10. Clipboard row, Copy / Paste As, format and block rows, Table or
Paragraph, Insert, link, image, code and raw-block items; source and
preview menus. Component pin 1b5fb07. Records the user's rule that
ElyMark must work, not match Typora one to one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 18:41:24 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f012eaa624 tasks: log the occasional development BuildId under parallel LOK tests (P3-15)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 18:40:08 -04:00
ZacharyZhang-NYandClaude Opus 5.5 13a2801bd1 R4.9: round 3 passed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:59:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 09fcb0b3ed ElyMark: R4.9 round 2 fixes, component pin 6c2d28c
Delete Table in an open block leaves focus on the document; Windows-only
import gated; menu tests run on Windows and Linux only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:48:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f84f990f90 ElyMark: R4.9 round 1 fixes
Menus act on an open block's selection (component 6d90351), clipboard
keys reach a focused field outside the document, Strike binds per
platform, platform-correct key tests, Delete Table in the table menu
(component 24ffc45), short comments. Pin 6b033f3 -> 24ffc45.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 17:18:57 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cb513d49de tasks: log the Windows-only ElyPDF and ElyTree test failures (P3-13, P3-14)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 16:38:58 -04:00
ZacharyZhang-NYandClaude Opus 5.5 539206297c R4.9: ElyMark menus and keys as Typora's, component pin 6b033f3
Paragraph and Format menus with Typora's items, labels and keys; Table
is a Paragraph submenu with row and column moves and source tidy.
Ctrl+/ toggles source, F8 focus, F9 typewriter, Ctrl+Shift+=/-/0 size,
Ctrl+Shift+C copies as Markdown, Ctrl+Shift+V pastes as plain text.
Refused commands show why, including refusals read in the background.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 16:38:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 4252ff5062 ely-table: name the header predicate type in the menu tests (clippy type_complexity)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 16:29:30 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7d4016eadb tasks: R4.8 table moves and tidy, three review rounds
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:56:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 91e54b5f48 R4.7: block commands, reviewed three rounds, round-3 fixes in component 0558f0c
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 15:04:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 135002fda9 R5.7: Gitea #1, ElyShow's slide strip scrolls with the wheel; R1 PASS
The strip's list sat in a ContextMenu host with no size, so it grew
with its content and never overflowed. The host now fills its column,
as ElyPDF's do; a 24-slide test wheels to the last slide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 14:36:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 35a7bbb904 R5.6: ElyShow right-click menus, PowerPoint-style; Impress table borders drag; R3 PASS
The canvas offers Ely's menu by target: slide (layouts, comment), text
(bullets, numbering, comment), shape or text box, picture (replace,
crop), table (insert, delete, merge), each item gated by the kernel's
state for its own command. Impress's table borders read as text and
drag through R5.3's path; patch 0047 reports every edge's bounds and
makes the left edge drag. A line past its bound never moves against
the drag, so a still press sends nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 14:26:27 -04:00
ZacharyZhang-NYandClaude Opus 5.5 411d32b97e R4.6: inline format commands, closed after three review rounds
Component 68d33b4..1d03545 (pushed). Round-3 findings fixed with
regressions; rounds spent. Two limits recorded in the task line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:16:35 -04:00
ZacharyZhang-NYandClaude Opus 5.5 87886c8a8d R5.5: ElyTable right-click menus, Excel-style; three rounds used, last fixes tested
Grid and header menus share ely_office::menu with ElyDoc. Each item's
availability is the kernel's state for the exact command it runs
(patch 0045). Patch 0046 lets a header select its row or column while
a shape is selected or its text is edited: the view shell ends the
drawing edit, keeps the typed text, leaves drawing mode, then marks.
Ely no longer sends Esc first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:02:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 07c0b43542 R4.5: a press lands on the pressed character [~]
Component 5af11fe..c35fdac (pushed): a press opens its block with the
caret on the pressed character; a secondary press moves the caret and
emits DocumentEvent::Pointed. Three review rounds failed; the round-3
findings are fixed with the reviewer's probes as regressions, unreviewed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:35:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 293643b8ae R5.4: ElyDoc's right-click and table borders checked in a real Windows window; agents.md records R5; R5.5 splits into ElyTable and ElyShow; R2 PASS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:35:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e6e5aea7a1 R5.3: ElyDoc drags the cursor's table borders, one kernel command per drop at the read's revision; three rounds used, last fixes tested
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:03:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d07f0ddb7c ely-mark: rustfmt the files R3.1 left unformatted
Formatting alone; cargo fmt --check failed on these six.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:12:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 256240c365 R4.4: export and copy read as the visual mode reads
HTML export, plain text and rich copy read each paragraph, heading,
cell and HTML block through the component's MarkdownReader (pin
1251221). Emoji, alerts, the TOC and inline tags match visual mode.
Footnotes sit in place as [n]; link titles are not written.

Three review rounds failed; round 3's one finding (a scan per
footnote over open marks) is fixed with a growth regression, unreviewed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:11:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9d80ba6f39 R5.2: ElyDoc's right-click menu follows Word by target, items enabled as the kernel's menu allows; two rounds, R2 PASS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:52:54 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e3c515b2f4 R4.3: block extensions read as GitHub shows them (component 8e5dad8); three rounds used, last fixes tested
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:50:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b5bfb77ba0 R5.1: in a table, ElyDoc's panel shows the Table group first (component 9ff403d keys folds by title); three rounds, R3 PASS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:37:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c3f6684a46 R4.2: inline extensions read as GitHub shows them (component 9f39d32); three rounds used, last fixes tested
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:49:32 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c4cb075ce3 R4.2: where Typora and GitHub read a file apart, ElyMark follows GitHub (user, 2026-10-13)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:06:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 962f274cc6 R4.1: icon-button rows in menus (component 1b8d678); three rounds used, last fixes tested
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:25:30 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0c9e2b0cea R4.0: ElyMark measures against Typora 1.14.10; review on gpt-6-astra max
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:08:01 -04:00
ZacharyZhang-NYandClaude Opus 5.5 991bfb78ad Site: download and Gitea icons on the hero links [skip ci]
The Gitea mark is its logo's two cup paths in the link color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:52:35 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f447cdca5c Site: downloads in the hero; hero links on one baseline, Source translated [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:47:15 -04:00
ZacharyZhang-NYandClaude Opus 5.5 854c360ea1 Site: show downloads across every release [skip ci]
/release.json adds the sum of every release asset's downloads; the
update manifests are the apps' checks, not downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:43:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 87c42fde0e Site: read the newest version from Gitea; links follow releases/latest [skip ci]
A worker answers /release.json from the Gitea API (Gitea sends no
CORS headers, so the page cannot ask it directly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:35:58 -04:00
ZacharyZhang-NYandClaude Opus 5.5 48306fcd16 Site: link downloads to v0.1.4 [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:33:25 -04:00
299 changed files with 17476 additions and 837 deletions

No files matched your search

+3
View File
@@ -15,6 +15,9 @@ jobs:
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
# Repository variables; both live in the runner's keychain.
ELY_SIGN_IDENTITY: ${{ vars.ELY_SIGN_IDENTITY }}
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
+1
View File
@@ -18,3 +18,4 @@ node_modules/
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
Generated
+31 -27
View File
@@ -275,7 +275,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -286,7 +286,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1751,7 +1751,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.3",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1875,7 +1875,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "ely-agent"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"ely-platform",
"ely-ui",
@@ -1884,7 +1884,7 @@ dependencies = [
[[package]]
name = "ely-doc"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"csv",
@@ -1906,7 +1906,7 @@ dependencies = [
[[package]]
name = "ely-files"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"aes-gcm",
"dunce",
@@ -1923,7 +1923,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=8fd3e431db3c57c67328869cffdcd43d5f7fd056#8fd3e431db3c57c67328869cffdcd43d5f7fd056"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=90bd37b5504809eed42f0a0a1e3f7e20ac6973ff#90bd37b5504809eed42f0a0a1e3f7e20ac6973ff"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -1960,6 +1960,7 @@ dependencies = [
"similar",
"smallvec",
"unicode-segmentation",
"unicode-width",
"usvg",
"vte",
"web-time",
@@ -1968,14 +1969,14 @@ dependencies = [
[[package]]
name = "ely-ipc"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"log",
]
[[package]]
name = "ely-lok"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"base64 0.23.1",
"dunce",
@@ -1996,7 +1997,7 @@ dependencies = [
[[package]]
name = "ely-mail"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"ely-platform",
"ely-ui",
@@ -2005,7 +2006,7 @@ dependencies = [
[[package]]
name = "ely-mark"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -2030,7 +2031,7 @@ dependencies = [
[[package]]
name = "ely-office"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"directories",
@@ -2051,7 +2052,7 @@ dependencies = [
[[package]]
name = "ely-pdf"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"dunce",
"ely-files",
@@ -2069,7 +2070,7 @@ dependencies = [
[[package]]
name = "ely-pdf-engine"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"cc",
"ely-ipc",
@@ -2085,7 +2086,7 @@ dependencies = [
[[package]]
name = "ely-platform"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"arboard",
@@ -2094,6 +2095,7 @@ dependencies = [
"directories",
"ely-ipc",
"env_logger",
"getrandom 0.4.3",
"image",
"libc",
"log",
@@ -2119,7 +2121,7 @@ dependencies = [
[[package]]
name = "ely-show"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2139,7 +2141,7 @@ dependencies = [
[[package]]
name = "ely-table"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"ely-gpui-component",
"ely-lok",
@@ -2156,7 +2158,7 @@ dependencies = [
[[package]]
name = "ely-tree"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"dunce",
"ely-files",
@@ -2176,7 +2178,7 @@ dependencies = [
[[package]]
name = "ely-ui"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"directories",
@@ -2185,6 +2187,7 @@ dependencies = [
"futures",
"gpui-pre",
"gpui-pre-platform",
"gpui-pre-windows",
"log",
"notify",
"semver",
@@ -2334,7 +2337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2979,6 +2982,7 @@ dependencies = [
"waker-fn",
"web-time",
"windows 0.62.2",
"zed-font-kit",
"zed-scap",
]
@@ -4501,7 +4505,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4666,7 +4670,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6418,7 +6422,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7006,7 +7010,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7346,7 +7350,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7696,7 +7700,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8495,7 +8499,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+2 -2
View File
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.4"
version = "0.1.5"
edition = "2024"
rust-version = "1.95"
license = "AGPL-3.0-or-later"
@@ -19,7 +19,7 @@ jiff = "0.2"
anyhow = "1"
directories = "6"
dunce = "1"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "8fd3e431db3c57c67328869cffdcd43d5f7fd056" }
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "90bd37b5504809eed42f0a0a1e3f7e20ac6973ff" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
+8 -1
View File
@@ -478,6 +478,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
解析、渲染、编辑命令与右键菜单以 Typora 1.14.10 的默认设置为对照,不输于它;可视模式仍是按下的块以原文编辑,不改为所见即所得的行内编辑(用户 2026-10-13 定)。Typora 是参照,不求一一相同:验收看每项功能可用,缺 Typora 的某项、次序或措辞不同不算缺陷(用户 2026-10-14 定)。[S25]
### 14.2 功能要求
| 编号 | 功能 | 完成要求 |
@@ -485,7 +487,7 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;照 Typora 默认显示 `:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块与 `[toc]` 目录;`==`、`^`、单个 `~` 照 GFM,不作高亮、上下标;Typora 与 GitHub 读法不同处随 GitHub(表情名与转义、标签与强调交叉);资源随包提供 |
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
@@ -496,6 +498,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
| MARK-15 | 格式命令 | 段落与格式菜单照 Typora 的项与快捷键:标题与段落、引用与列表、插入块、行内格式与清除格式;每条命令一步撤销,用不了的地方说明原因 |
| MARK-16 | 右键菜单 | 右键处的块、链接、图片、表格或代码决定菜单内容,照 Typora 的布局(剪贴板与格式为图标行);预览只读时只留复制类 |
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
@@ -933,3 +937,6 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
`https://support.typora.io/Shortcut-Keys/`
+30 -10
View File
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin 8fd3e43,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin 90bd37b,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -55,7 +55,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` |
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal, DocumentTarget, TargetBlock}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table`;右键菜单听 `DocumentEvent::Pointed` |
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
@@ -84,7 +84,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| 组件内部文案未接 i18n(Ely 用到的组件已接:提示、默认占位、补全空行、字号示例,6df9ab0;查找框、排序与筛选、密码开关,a4bf696;`i18n/tests/shipped.rs` 防回退;其余组件之外:欢迎页标题、菜单项等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
## gpui 规则
@@ -123,7 +123,7 @@ scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,fast)
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
@@ -159,7 +159,7 @@ tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
tests/scripts/one-instance.sh 第二份 ElyDoc 把文件交给第一份后退出,第一份开出它、工作目录不动
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
@@ -170,10 +170,12 @@ tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
tests/scripts/dock-macos.sh dist 里 ElyDoc、ElyTable、ElyShow 与包外的 target/debug/ely-doc 各启动数次:LOK worker 从未登记为程序坞应用,图块照出
tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原有的文件经两次安装与卸载都在,旧版按注册表清理,文件被占用或程序在运行时什么也不改,写不进时放回旧版(缺 makensis 时 SKIP)
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
@@ -197,7 +199,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
@@ -225,7 +227,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git(2026-10-15 起 Windows 同此:`git::own` 先找本应用旁的,再按 `apps` 的同级查找与 macOS 的登记找 ElyTree 旁的,都没有即 `git::NoGit`,ElyMark 的 Git 项写明要装 ElyTree;Windows 的 ElyMark 原随带一份,用户问起后去掉。Linux 的 ElyMark 照带:AppImage 自成一体,找不到别的映像里的 Git,deb、rpm、Arch 包与它同一 AppDir,R6.6)。Windows 安装程序只动自己装下的:装好后写下所装的每个文件与文件夹(`.ely-installed`,由 `packaging/windows/files.sh` 按分装目录生成),升级与卸载只移去其中的文件,文件夹空了才删;清单之前的版本只在注册表记下的安装目录里按旧的通配名与其文件夹里的全部文件移去;先查每个文件能否写(运行中或被占用即不能,文件夹里的也查),再逐个挪进 `.ely-old`(按清单次序编号,备份路径不随原路径变长,近 260 字的路径照样升级与卸载),任一项挪不动即放回、什么也不改、退出码 2;卸载时清单本身也先查、删不掉即放回全部;新文件的位置已有不属于本应用的文件即拒绝、什么也不改、退出码 2;写到一半失败即删去本次写下的、放回旧版;升级后不留旧的 `git` 与 CEF 文件(R6.11,`tests/scripts/windows-installer.sh`)。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、Line truncated
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
@@ -265,7 +267,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
@@ -283,6 +285,24 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格(2026-10-15 起屏上读作换行,见下),嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、Line truncated
- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
- 2026-10-14(用户定):Typora 是参照,不求一一相同;ElyMark 的验收与评审只看每项功能是否可用,缺 Typora 的某行、次序或措辞不同不算缺陷。
- 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。
- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。第 3 轮后(组件库 f1771b3,最后一轮,修完不再审查):无外侧竖线的行里清空首格或末格时写回一个竖线(`| | beta`、`alpha | |`),列数不变;插入点照写回的转义文字报出(键入竖线后不再落在多字节字中间);改过一格后右键邻格照常给出目标;长文(后台解析)里改格后 Tab 等到解析完成再打开下一格。
- 2026-10-15(用户报:二级菜单选不到,往子菜单移动时经过下面的项会弹出它们的子菜单,七个应用都如此;用户定照桌面系统的悬停意图做:安全三角加打开与关闭的延时):级联菜单(R6.2,组件库 d4fdb69,`ContextMenu`、`DropdownMenu`、`MenuBar` 共用的面板)。菜单面板挡住其下的元素(原先子菜单被挤回、盖在父菜单上时,被盖住的父菜单行照收悬停);子菜单右侧放不下且左侧放得下即开在所在行的左侧,从右滑入,不盖父菜单的行(两侧都放不下时贴窗口边,仍被面板挡住);指针停在有子菜单的行上 150 毫秒才打开;子菜单开着时,指针落在“上一位置与子菜单近侧上下两角”构成的三角形里的另一行,等 300 毫秒,指针仍在该行才切换,其间到达子菜单即作废;三角形外的行即刻标记。键盘与按下照旧即刻打开。实现在组件 `menus::aim`。第 1 轮后(组件库 5a53c08):指针离开一行(到边距、分隔线、禁用的行或菜单外)即作废其延时,回到该行重新计时;菜单在别处重开即忘掉旧的悬停;图标按钮行同样受三角形保护。
- 2026-10-15(用户报:ElyMark 的最近列表清不掉):侧栏“清除最近文件”(R6.3,98692cb)写空 `recent.json`(`ely_files::recent::clear`),失败即提示。会话恢复的标签不把文件加回列表(`Desk::remembered` 预先记下),清空在重启后照旧;显式打开照样加入,已开着的文件(按文件身份,含硬链接,`Sessions::is_open`)只切到该标签,也加入列表。
- 2026-10-15(用户报:ElyMark 代码块后的“你好↵测试”离开输入框后成了“你好 测试”):单个换行照 Typora 的默认(编辑时保留,导出与打印不保留):组件库 93efa7f 起 `MarkdownReader::new`(屏上:可视、预览、右键与命令的读法)把软换行读作换行,`MarkdownReader::joined`(输出)照 GFM 读作空格;标题的大纲与目录文字照旧一行。ElyMark 的 HTML、PDF、富文本复制与纯文本导出用 `joined`(R6.7)。组件库 c31d092 与 26af4be:画出成一个换行的 CRLF 对回其起点(原先按在 CRLF 行末键入落在 CR 与 LF 之间),行内代码照留两字则逐字对回。
- 2026-10-15(用户报:ElyMark 源码模式“测”后的插入点画在“测”的中间):组件库 `CodeEditor` 原按“列 × m 的宽”放插入点、选区、按下处与输入法框,中日韩字与表情约宽两格。组件库 9fb607b 起按 gpui 排好的这一行定位(`editor::measure`,含着色带来的字重),块状与下划线插入点取所在字的宽;标尺与缩进参考线仍按列(R6.8)。上下移动仍按字数记目标列。cd61653:按下处取最近的字首或行末(gpui 的 `closest_index_for_x` 越过末字字首即给行末);上次绘制得出的括号与配对位置只属当时的文字(`Buffer` 每次改动换一个戳),输入法改字后、下次绘制前的测量不用它们(原先把新字切进不同字体段,DirectWrite 崩溃)。
- 2026-10-15(用户报:ElyMark 三、四级标题不显眼):组件库 5954132 起标题字号随正文按 Typora 的倍数(一至六级 2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色,行高为字号的 1.3 倍(原为固定的 24、20、16、14 像素,正文 13,三、四级只是中等字重)(R6.9)。90bd37b:粗体与标题同一字重(原粗体为半粗,标题里的粗体反而更细,正文粗体随之由半粗改为粗,同 Typora);标题行高按自身字号取 1.3 倍。Windows 真窗口核对(`.work/r69`):可视模式各级标题分明、“你好↵测试”两行,源码模式“测”后的插入点落在两字之间。
- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动 AppKit 之前(`Lok::hook` 打开内核之后、调钩子之前)把本进程主包信息里的 `LSUIElement` 设为真,AppKit 登记时即为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口);钩子之后核对激活策略是辅助,不是即报错、不启动(`ffi/accessory.rs`);包外的 worker(`cargo run`、单测)无包可登记,策略为禁止,此时改设辅助(R6.12 第 2 轮)。原先在登记之后才改策略,审查实测 15 次启动有 11 次先登记为 Foreground 约 30 毫秒(R6.12 第 1 轮)。`tests/scripts/dock-macos.sh` 连续启动三个应用与包外的开发构建,从 worker 启动起采样其类型到首块图块,见 Foreground 即失败(R6.12)。
- 2026-10-15(用户报:ElyShow 编辑区滚轮与上下键不换页,三端):内核在未选中对象时把上下键当作滚动窗口(`fupoor.cxx`),Ely 的画布整页适合、无处可滚,滚轮同样。`LokView::turn_parts`(ElyShow 首次适合时调用)之后:整页可见时滚轮换页(`view/turn.rs`):滚轮的行按本平台每格的行数(`ely_lok::notch`:macOS 1,Windows 取系统设置,X11 与 Wayland 为 3)折成格,整格的行每格一页(快速连滚与一次多格都照格数);一串行里出现不足一格的(X11、Windows 的触控板)即为平滑滚动,此后到停顿前不再换页,像素(macOS、Wayland 的触控板)同按手势:一次手势换一页(满一格或 50 像素),惯性不算;有起止的手势(macOS)只到结束才算完,没有起止的停 200 毫秒即为下一次。X11 的滚轮与触控板同为行、gpui 不分,以整格开头的触控板滑动在出现小数之前会按格换页(已知限制)。未选中对象、未编辑文字时上下键换页。换页一律经有序输入队列交 worker(`TURN`):worker 按之前各输入回报后的状态判定,有选区、选中对象或插入点即把方向键照常交内核,否则当场切页(到首末页即停,同缩略图条的 `setPart`),视图在落地时换上 worker 所示的页,其后排队的键与点按都落在新页上(Tab 后紧接的 Down 移动所选对象;Down、Tab、Down 是换页后在新页上选中并移动);ElyShow 后台读回的当前页只在视图已无待落地输入时采用(R6.13)。
+1 -1
View File
@@ -48,7 +48,7 @@ impl Docs {
.size_full()
.child(tab.view.clone())
.children(self.find_box(cx))
.children(self.spelling_menu(cx))
.children(self.context_menu(cx))
.into_any_element();
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
let fonts = tab.view.read(cx).missing_fonts().join(", ");
+2 -2
View File
@@ -37,7 +37,7 @@ pub struct Docs {
pub review: Entity<Review>,
/// What the right column shows.
pub side: Side,
pub offer: Option<crate::spell::Offer>,
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
pub merge: Entity<crate::merge::Merge>,
pub navigator: Entity<crate::navigate::Navigator>,
/// The left column shows the navigator.
@@ -88,7 +88,7 @@ impl Host for Docs {
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
self.count(id, view, cx);
self.found(id, view, cx);
self.offer_spelling(id, view, cx);
self.offer_menu(id, view, cx);
}
/// A dialog about a closed tab goes with it.
+1 -1
View File
@@ -7,13 +7,13 @@ mod compare;
mod docs;
mod files;
mod insert;
mod menu;
mod merge;
mod navigate;
mod output;
mod props;
mod refs;
mod review;
mod spell;
use ely_platform::AppId;
+238
View File
@@ -0,0 +1,238 @@
//! The right-click menu, laid out as Word's (DOC-02, DOC-07, DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView,
menu::{Offered, Target},
};
use ely_office::menu::{self, Entry, Offer};
use gpui::{App, Context, Entity, Window};
use crate::docs::Docs;
use crate::refs::{InsertCaption, InsertComment, InsertLink};
use ely_office::prelude::*;
/// One thing the menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
Link,
Comment,
ColumnLeft,
ColumnRight,
RowAbove,
RowBelow,
DeleteRows,
DeleteColumns,
DeleteTable,
Merge,
SplitColumns,
SplitRows,
EvenRows,
EvenColumns,
Inline,
Square,
TopBottom,
InFront,
Front,
Forward,
Back,
Backward,
Caption,
}
const TABLE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.insert",
&[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow],
),
Entry::Sub(
"doc.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable],
),
Entry::Row(Pick::Merge),
Entry::Sub("doc.menu.split", &[Pick::SplitColumns, Pick::SplitRows]),
Entry::Row(Pick::EvenRows),
Entry::Row(Pick::EvenColumns),
Entry::Line,
Entry::Row(Pick::Link),
Entry::Row(Pick::Comment),
];
const FRAME: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.wrap",
&[Pick::Inline, Pick::Square, Pick::TopBottom, Pick::InFront],
),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Caption),
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry<Pick>] = &[Entry::Line, Entry::Row(Pick::Link), Entry::Row(Pick::Comment)];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::Link => ("doc.menu.link", ".uno:InsertHyperlink"),
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
Pick::SplitColumns => ("doc.menu.split_columns", ".uno:SplitCell"),
Pick::SplitRows => ("doc.menu.split_rows", ".uno:SplitCell"),
Pick::EvenRows => ("doc.menu.even_rows", ".uno:DistributeRows"),
Pick::EvenColumns => ("doc.menu.even_columns", ".uno:DistributeColumns"),
Pick::Inline => ("doc.menu.inline", ".uno:SetAnchorToChar"),
Pick::Square => ("doc.menu.square", ".uno:WrapOn"),
Pick::TopBottom => ("doc.menu.top_bottom", ".uno:WrapOff"),
Pick::InFront => ("doc.menu.in_front", ".uno:WrapThrough"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
Pick::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"),
}
}
}
impl Pick {
/// The kernel offers it and it runs now.
pub(crate) fn on(self, offered: &Offered) -> bool {
offered.allows(menu::Pick::row(self).1)
}
}
impl Docs {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) {
let error = format!("a {:?} menu in a text document", offered.target);
let text = Docs::text(cx, "office.error.engine", &[("error", &error)]);
return self.toast(text, Severity::Danger, cx);
}
let suggestions = offered.spelling.as_ref().map_or(0, |spelling| spelling.suggestions.len());
log::info!("doc: {suggestions} suggestions");
self.offer = Some(Offer::new(self.offer.as_ref(), id, offered, at));
cx.notify();
}
/// The menu's host, for the active tab.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("doc-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("doc: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Link => return self.on_link(&InsertLink, window, cx),
Pick::Comment => return self.on_comment(&InsertComment, window, cx),
Pick::Caption => return self.on_caption(&InsertCaption, window, cx),
Pick::ColumnLeft => ("table.column_left", &[]),
Pick::ColumnRight => ("table.column_right", &[]),
Pick::RowAbove => ("table.row_above", &[]),
Pick::RowBelow => ("table.row_below", &[]),
Pick::DeleteRows => ("table.delete_rows", &[]),
Pick::DeleteColumns => ("table.delete_columns", &[]),
Pick::DeleteTable => ("table.delete", &[]),
Pick::Merge => ("table.merge", &[]),
Pick::SplitColumns => ("table.split", &["2", "false"]),
Pick::SplitRows => ("table.split", &["2", "true"]),
Pick::EvenRows => ("table.even_rows", &[]),
Pick::EvenColumns => ("table.even_columns", &[]),
Pick::Inline => ("image.anchor_as_char", &[]),
Pick::Square => ("image.wrap_on", &[]),
Pick::TopBottom => ("image.wrap_off", &[]),
Pick::InFront => ("image.wrap_through", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("doc: {pick:?} with no document");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// Spelling first, then the clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Docs::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!pick.on(offered))
.on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx)))
};
let mut menu = Menu::new();
if let Some(spelling) = &offered.spelling {
for (label, rule) in &spelling.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if spelling.suggestions.is_empty() {
menu = menu.item(MenuItem::new(Docs::text(cx, "doc.spell.none", &[])).disabled(true));
}
let this = this.clone();
let ignore = MenuItem::new(Docs::text(cx, "doc.spell.ignore_all", &[])).on_click(
move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
},
);
menu = menu.item(ignore).separator();
}
let menu = menu::clipboard(menu, [Pick::Cut, Pick::Copy, Pick::Paste], item);
let rows = match offered.target {
Target::Table => TABLE,
Target::Frame => FRAME,
Target::Text => TEXT,
Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"),
};
menu::entries(menu, rows, cx, |pick| pick.on(offered), item)
}
#[cfg(test)]
mod tests;
+117
View File
@@ -0,0 +1,117 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, menu::Target};
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
use super::Pick;
use crate::docs::Docs;
use crate::tests::{blank, docs, settled, text_of, type_into, with_window, written};
use ely_office::prelude::*;
/// The view with the menu's host over it.
struct Host(Entity<Docs>, Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let menu = self.0.update(cx, |docs, cx| docs.context_menu(cx));
gpui::div().relative().size_full().child(self.1.clone()).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// The caret's middle, in window pixels.
fn caret(cx: &mut VisualTestContext, view: &Entity<LokView>) -> Point<Pixels> {
frames(cx);
cx.update(|_, app| view.read(app).cursor_bounds().expect("a caret").center())
}
/// Right-clicks through the view and waits for the target's menu.
fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels>, target: Target) {
cx.update(|_, app| docs.update(app, |docs, _| docs.offer = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
let shown = |app: &gpui::App| {
docs.read(app)
.offer
.as_ref()
.is_some_and(|offer| offer.what.target == target)
};
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| shown(app)) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no {target:?} menu");
}
#[test]
fn a_right_click_opens_word_rows_where_it_lands() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Text");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
let window = cx.add_window(|_, _| Host(docs.clone(), view.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let text = caret(&mut shown, &view) - gpui::point(gpui::px(8.0), gpui::px(0.0));
right_click(&mut shown, &docs, text, Target::Text);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - text;
assert!(
off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0),
"the menu opens at the click, not the caret: {off:?}"
);
let on = |shown: &mut VisualTestContext, pick: Pick| {
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").what))
};
assert!(on(&mut shown, Pick::Comment));
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
assert!(!on(&mut shown, Pick::Cut), "nothing is selected");
with_window(&mut cx, &docs, |docs, _, cx| docs.insert_table(2.0, 2.0, cx));
settled(&cx, &docs);
let cell = caret(&mut shown, &view);
right_click(&mut shown, &docs, cell, Target::Table);
assert!(on(&mut shown, Pick::RowBelow) && on(&mut shown, Pick::SplitRows));
assert!(!on(&mut shown, Pick::Merge), "one cell does not merge");
for pick in [Pick::RowBelow, Pick::ColumnRight] {
with_window(&mut cx, &docs, |docs, window, cx| docs.choose(pick, window, cx));
settled(&cx, &docs);
}
let path = dir.path().join("grown.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let flat = text_of(&mut cx, &docs, &path);
assert_eq!(flat.matches("<table:table-row").count(), 3, "a row below");
assert_eq!(flat.matches("<table:table-cell").count(), 9, "and a column right");
}
#[test]
fn link_follows_the_kernel_flag() {
let menu = |enabled: bool| {
let json = format!(
r#"{{"menu":[{{"text":"x","type":"command","command":".uno:InsertHyperlink","enabled":"{enabled}"}}]}}"#
);
ely_lok::menu::read(&json).expect("the menu parses")
};
assert!(Pick::Link.on(&menu(true)));
assert!(!Pick::Link.on(&menu(false)), "protected text gets no link");
}
+7 -7
View File
@@ -250,15 +250,15 @@ impl Render for Props {
let grid = if graphic {
grid.group(self.object_group(cx))
} else {
let grid = grid
.group(self.text_group(window, cx))
.group(self.paragraph_group(cx))
.group(self.style_group(cx))
.group(self.page_group(cx));
match self.state(".uno:DeleteTable", cx).as_deref() {
// In a table, its group leads, as in Word.
let grid = match self.state(".uno:DeleteTable", cx).as_deref() {
Some("enabled") => grid.group(self.table_group(cx)),
_ => grid,
}
};
grid.group(self.text_group(window, cx))
.group(self.paragraph_group(cx))
.group(self.style_group(cx))
.group(self.page_group(cx))
};
div()
.size_full()
+7 -2
View File
@@ -2,7 +2,7 @@
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
use gpui::{App, Context, SharedString, Window};
use gpui::{App, Context, InteractiveElement, ParentElement, SharedString, Window, div};
use super::Props;
use crate::docs::Docs;
@@ -77,7 +77,12 @@ impl Props {
PropertyGroup::new(tip("doc.props.text"))
.row(tip("doc.props.font"), font)
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
.row(tip("doc.props.effects"), effects)
.row(
tip("doc.props.effects"),
div()
.debug_selector(|| "doc-props-text".into())
.child(effects),
)
.row(
tip("doc.props.color"),
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
+8 -6
View File
@@ -2,7 +2,7 @@
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
use ely_office::panel::{button, color_state, palette, row, send, toggle};
use gpui::{AnyElement, Context, IntoElement, SharedString};
use gpui::{AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, div};
use super::Props;
use crate::docs::Docs;
@@ -14,11 +14,13 @@ impl Props {
PropertyGroup::new(tip("doc.props.table"))
.row(
tip("doc.props.rows"),
row([
button(self, "table.row_above", "doc.props.above_row", why, cx),
button(self, "table.row_below", "doc.props.below_row", why, cx),
button(self, "table.delete_rows", "doc.props.delete", why, cx),
]),
div()
.debug_selector(|| "doc-props-table".into())
.child(row([
button(self, "table.row_above", "doc.props.above_row", why, cx),
button(self, "table.row_below", "doc.props.below_row", why, cx),
button(self, "table.delete_rows", "doc.props.delete", why, cx),
])),
)
.row(
tip("doc.props.columns"),
+80
View File
@@ -1,5 +1,6 @@
use std::{cell::RefCell, rc::Rc};
use ely_gpui_component::theme::Theme;
use ely_lok::LokError;
use ely_office::panel::Panel as _;
use gpui::{Entity, TestAppContext};
@@ -296,3 +297,82 @@ fn quick_spacing_edits_keep_each_other() {
"both quick edits land"
);
}
/// A window root that shows the panel.
struct Shown(Entity<Props>);
impl gpui::Render for Shown {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
gpui::div().size_full().child(self.0.clone())
}
}
/// The top of the element marked `selector`.
fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option<gpui::Pixels> {
// Past reduced motion's 1ms folds.
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(2));
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
cx.debug_bounds(selector).map(|bounds| bounds.origin.y)
}
#[test]
fn in_a_table_the_table_group_comes_first_and_folds_stay_put() {
const DOWN: i32 = 1024;
const UP: i32 = 1025;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Table");
let props = panel(&mut cx, &docs);
let window = cx.add_window(|_, _| Shown(props.clone()));
let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx);
assert_eq!(
top(&mut shown, "doc-props-table"),
None,
"no table, no group"
);
let header = gpui::point(gpui::px(10.0), gpui::px(12.0));
shown.simulate_click(header, gpui::Modifiers::none());
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds");
shown.simulate_click(header, gpui::Modifiers::none());
assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.insert_table(3.0, 2.0, cx)
});
let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app);
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
let table = top(&mut shown, "doc-props-table").expect("the table group");
let text = top(&mut shown, "doc-props-text").expect("the text group");
assert!(table < text, "the table group leads: {table:?} vs {text:?}");
keys(&mut cx, &docs, &[UP]);
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
shown.simulate_click(header, gpui::Modifiers::none());
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again");
keys(&mut cx, &docs, &[DOWN]);
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens");
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded");
keys(&mut cx, &docs, &[UP]);
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
assert_eq!(
top(&mut shown, "doc-props-text"),
None,
"and after the table"
);
}
-85
View File
@@ -1,85 +0,0 @@
//! Spelling suggestions from the kernel's context menu (DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::LokView;
use gpui::{Context, Entity, IntoElement, Pixels, Point};
use crate::docs::Docs;
use ely_office::prelude::*;
/// A misspelled word's menu, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) suggestions: Vec<(String, String)>,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl Docs {
/// Turns the kernel's context menu into a spelling offer.
pub(crate) fn offer_spelling(
&mut self,
id: u64,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) {
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let spelling = match ely_lok::review::spelling(&json) {
Ok(spelling) => spelling,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let Some(spelling) = spelling else {
log::info!("doc: the kernel menu has no spelling");
return;
};
let Some(caret) = view.read(cx).cursor_bounds() else {
log::warn!("doc: a spelling menu without a caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
self.offer = Some(Offer {
tab: id,
suggestions: spelling.suggestions,
request: (number, caret.bottom_left()),
});
cx.notify();
}
/// The spelling menu's host, for the active tab.
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let this = cx.entity();
let mut menu = Menu::new();
for (label, rule) in &offer.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if offer.suggestions.is_empty() {
let none = Docs::text(cx, "doc.spell.none", &[]);
menu = menu.item(MenuItem::new(none).disabled(true));
}
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
let menu = menu
.separator()
.item(MenuItem::new(ignore).on_click(move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
}));
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
}
}
+2 -1
View File
@@ -126,7 +126,8 @@ fn a_misspelled_word_offers_suggestions() {
settle(&cx, |app| docs.read(app).offer.is_some());
let rule = cx.update(|app| {
let offer = docs.read(app).offer.as_ref().expect("an offer");
let (_, rule) = offer
let spelling = offer.what.spelling.as_ref().expect("a spelling menu");
let (_, rule) = spelling
.suggestions
.iter()
.find(|(label, _)| label == "The")
+234
View File
@@ -0,0 +1,234 @@
//! Typora's Paragraph and Format menus at the caret (MARK-15).
use ely_gpui_component::{
documents::{BlockCommand, EditRefusal, InlineFormat, MarkdownDocument, TableRefusal},
primitives::Severity,
};
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, SharedString,
actions,
};
use crate::{desk::Desk, keys::item};
actions!(
mark,
[
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Paragraph,
Promote,
Demote,
MathBlock,
CodeFences,
Quote,
OrderedList,
BulletList,
TaskList,
ParagraphBefore,
ParagraphAfter,
LinkReference,
Footnote,
Rule,
Toc,
FrontMatter,
Strong,
Emphasis,
Underline,
InlineCode,
Strike,
Hyperlink,
InsertImage,
ClearFormat,
DeleteBlock
]
);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-1", Heading1, at),
KeyBinding::new("secondary-2", Heading2, at),
KeyBinding::new("secondary-3", Heading3, at),
KeyBinding::new("secondary-4", Heading4, at),
KeyBinding::new("secondary-5", Heading5, at),
KeyBinding::new("secondary-6", Heading6, at),
KeyBinding::new("secondary-0", Paragraph, at),
KeyBinding::new("secondary-=", Promote, at),
KeyBinding::new("secondary--", Demote, at),
KeyBinding::new("secondary-shift-m", MathBlock, at),
KeyBinding::new("secondary-shift-k", CodeFences, at),
KeyBinding::new("secondary-shift-q", Quote, at),
KeyBinding::new("secondary-{", OrderedList, at),
KeyBinding::new("secondary-}", BulletList, at),
KeyBinding::new("secondary-shift-x", TaskList, at),
KeyBinding::new("secondary-b", Strong, at),
KeyBinding::new("secondary-i", Emphasis, at),
KeyBinding::new("secondary-u", Underline, at),
KeyBinding::new("secondary-~", InlineCode, at),
KeyBinding::new("alt-%", Strike, at),
KeyBinding::new("secondary-k", Hyperlink, at),
KeyBinding::new("secondary-shift-i", InsertImage, at),
KeyBinding::new("secondary-\\", ClearFormat, at),
]);
}
pub fn paragraph_menu(cx: &App) -> Menu {
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
Menu::new(text(cx, "mark.menu.paragraph", &[])).items([
item("mark.block.h1", Box::new(Heading1)),
item("mark.block.h2", Box::new(Heading2)),
item("mark.block.h3", Box::new(Heading3)),
item("mark.block.h4", Box::new(Heading4)),
item("mark.block.h5", Box::new(Heading5)),
item("mark.block.h6", Box::new(Heading6)),
MenuItem::separator(),
item("mark.block.paragraph", Box::new(Paragraph)),
MenuItem::separator(),
item("mark.block.promote", Box::new(Promote)),
item("mark.block.demote", Box::new(Demote)),
MenuItem::separator(),
MenuItem::submenu(crate::tools::table_menu(cx)),
item("mark.block.math", Box::new(MathBlock)),
item("mark.block.code", Box::new(CodeFences)),
MenuItem::separator(),
item("mark.block.quote", Box::new(Quote)),
MenuItem::separator(),
item("mark.block.ordered", Box::new(OrderedList)),
item("mark.block.bullet", Box::new(BulletList)),
item("mark.block.task", Box::new(TaskList)),
MenuItem::separator(),
item("mark.block.before", Box::new(ParagraphBefore)),
item("mark.block.after", Box::new(ParagraphAfter)),
MenuItem::separator(),
item("mark.block.link_reference", Box::new(LinkReference)),
item("mark.block.footnote", Box::new(Footnote)),
MenuItem::separator(),
item("mark.block.rule", Box::new(Rule)),
item("mark.block.toc", Box::new(Toc)),
item("mark.block.front_matter", Box::new(FrontMatter)),
])
}
pub fn format_menu(cx: &App) -> Menu {
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
Menu::new(text(cx, "mark.menu.format", &[])).items([
item("mark.format.strong", Box::new(Strong)),
item("mark.format.emphasis", Box::new(Emphasis)),
item("mark.format.underline", Box::new(Underline)),
item("mark.format.code", Box::new(InlineCode)),
MenuItem::separator(),
item("mark.format.strike", Box::new(Strike)),
MenuItem::separator(),
item("mark.format.link", Box::new(Hyperlink)),
item("mark.format.image", Box::new(InsertImage)),
MenuItem::separator(),
item("mark.format.clear", Box::new(ClearFormat)),
])
}
/// What to tell the user for `why`.
pub(crate) fn refusal(why: EditRefusal, cx: &App) -> SharedString {
let key = match why {
EditRefusal::Preview => "mark.preview.read_only",
EditRefusal::Code => "mark.refused.code",
EditRefusal::Math => "mark.refused.math",
EditRefusal::Html => "mark.refused.html",
EditRefusal::FrontMatter => "mark.refused.front_matter",
EditRefusal::Outside => "mark.refused.outside",
EditRefusal::Table => "mark.refused.table",
EditRefusal::NoImage => "mark.refused.no_image",
EditRefusal::Present => "mark.refused.present",
EditRefusal::Across => "mark.refused.across",
EditRefusal::TooLong => "mark.refused.too_long",
EditRefusal::Busy => "mark.refused.busy",
EditRefusal::Changed => "mark.refused.changed",
EditRefusal::Misread => "mark.refused.misread",
EditRefusal::Cells(TableRefusal::Outside) => "mark.table.none",
EditRefusal::Cells(TableRefusal::Kept) => "mark.table.kept",
EditRefusal::Cells(TableRefusal::Beyond) => "mark.table.beyond",
EditRefusal::Cells(TableRefusal::Mixed) => "mark.table.mixed",
EditRefusal::Cells(TableRefusal::Edge) => "mark.table.edge",
};
text(cx, key, &[])
}
/// Each action to its document edit.
macro_rules! edits {
($root:expr, $cx:expr, $($action:ident => $edit:expr),* $(,)?) => {
$root$(.on_action($cx.listener(|desk, _: &$action, _, cx| desk.edit($edit, cx))))*
};
}
pub(crate) use edits;
impl Desk {
/// `edit` on the shown document; a refusal is said.
pub(crate) fn edit(
&mut self,
edit: impl FnOnce(
&mut MarkdownDocument,
&mut Context<MarkdownDocument>,
) -> Result<(), EditRefusal>,
cx: &mut Context<Self>,
) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().clone();
if let Err(why) = document.update(cx, edit) {
self.toast(refusal(why, cx), Severity::Info, cx);
}
}
pub fn block_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use BlockCommand as B;
use InlineFormat as F;
edits!(root, cx,
Heading1 => |d, cx| d.block(B::Heading(1), cx),
Heading2 => |d, cx| d.block(B::Heading(2), cx),
Heading3 => |d, cx| d.block(B::Heading(3), cx),
Heading4 => |d, cx| d.block(B::Heading(4), cx),
Heading5 => |d, cx| d.block(B::Heading(5), cx),
Heading6 => |d, cx| d.block(B::Heading(6), cx),
Paragraph => |d, cx| d.block(B::Paragraph, cx),
Promote => |d, cx| d.block(B::Promote, cx),
Demote => |d, cx| d.block(B::Demote, cx),
MathBlock => |d, cx| d.block(B::MathBlock, cx),
CodeFences => |d, cx| d.block(B::CodeBlock, cx),
Quote => |d, cx| d.block(B::Quote, cx),
OrderedList => |d, cx| d.block(B::OrderedList, cx),
BulletList => |d, cx| d.block(B::BulletList, cx),
TaskList => |d, cx| d.block(B::TaskList, cx),
ParagraphBefore => |d, cx| d.block(B::ParagraphBefore, cx),
ParagraphAfter => |d, cx| d.block(B::ParagraphAfter, cx),
LinkReference => |d, cx| d.block(B::LinkDefinition, cx),
Footnote => |d, cx| d.block(B::Footnote, cx),
Rule => |d, cx| d.block(B::Rule, cx),
Toc => |d, cx| d.block(B::Toc, cx),
FrontMatter => |d, cx| d.block(B::FrontMatter, cx),
Strong => |d, cx| d.format(F::Strong, cx),
Emphasis => |d, cx| d.format(F::Emphasis, cx),
Underline => |d, cx| d.format(F::Underline, cx),
InlineCode => |d, cx| d.format(F::Code, cx),
Strike => |d, cx| d.format(F::Strike, cx),
Hyperlink => |d, cx| d.format(F::Link, cx),
InsertImage => |d, cx| d.format(F::Image, cx),
ClearFormat => |d, cx| d.format(F::Clear, cx),
DeleteBlock => |d, cx| d.block(B::Delete, cx),
)
}
}
// macOS menus are the system's, not drawn.
#[cfg(test)]
mod context;
#[cfg(all(test, not(target_os = "macos")))]
mod menus;
#[cfg(test)]
mod tests;
+251
View File
@@ -0,0 +1,251 @@
//! The right-click menu, driven as a user would (R4.10).
use ely_gpui_component::theme::{ActiveTheme as _, TextSize};
use gpui::{
ClipboardItem, Entity, Modifiers, MouseButton, Point, TestAppContext, VisualTestContext, point,
px,
};
use super::tests::{current, document, opened, toasted};
use crate::{desk::Desk, view::Mode};
/// A right press on block `ix`'s first line, character `k`.
fn right_on(ix: usize, k: usize, cx: &mut VisualTestContext) {
let block = cx
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
.expect("the block is drawn");
let size = cx.update(|window, cx| {
cx.theme()
.text_size(TextSize::Base)
.to_pixels(window.rem_size())
});
// The test text system sets glyphs 0.6 em apart.
let x = block.left() + px(13.0) + size * 0.6 * (k as f32 + 0.25);
right_at(point(x, block.top() + px(10.0)), cx);
}
fn right_at(at: Point<gpui::Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
cx.run_until_parked();
}
/// Arrow `keys`, then Enter; menus choose on its release.
fn choose(keys: &str, cx: &mut VisualTestContext) {
if !keys.is_empty() {
cx.simulate_keystrokes(keys);
}
cx.simulate_keystrokes("enter");
let enter = gpui::Keystroke::parse("enter").expect("a key");
cx.simulate_event(gpui::KeyUpEvent { keystroke: enter });
cx.run_until_parked();
}
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
cx.read_from_clipboard().and_then(|item| item.text())
}
fn switch(desk: &Entity<Desk>, mode: Mode, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| desk.switch(mode, window, cx));
cx.run_until_parked();
}
#[gpui::test]
fn paste_from_the_menu_lands_at_the_pressed_caret(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
cx.write_to_clipboard(ClipboardItem::new_string("New ".into()));
right_on(0, 0, cx);
choose("down", cx);
assert_eq!(current(&desk, cx), "New Intro\n");
}
#[gpui::test]
fn a_link_opens_and_copies_its_address(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("[docs](https://x.test)\n", cx);
right_on(0, 1, cx);
choose("down", cx);
assert_eq!(cx.opened_url().as_deref(), Some("https://x.test"));
right_on(0, 1, cx);
choose("down down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("https://x.test"));
}
#[gpui::test]
fn a_code_block_copies_its_code_and_deletes_by_name(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("```rust\nlet x = 1;\n```\n\nNext\n", cx);
right_on(0, 0, cx);
choose("down down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("let x = 1;"));
right_on(0, 0, cx);
choose("up", cx);
assert!(
!current(&desk, cx).contains("let x"),
"{}",
current(&desk, cx)
);
}
#[gpui::test]
fn a_quote_is_deleted_from_the_last_row(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("> quoted\n\nNext\n", cx);
right_on(0, 1, cx);
choose("up", cx);
let text = current(&desk, cx);
assert!(!text.contains("quoted") && text.contains("Next"), "{text}");
}
#[gpui::test]
fn a_picture_shows_its_file_or_goes(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Lead.\n\n![pic](missing.png)\n", cx);
right_on(1, 1, cx);
choose("down", cx);
assert_eq!(toasted(&desk, "no file here", cx), 1);
right_on(1, 1, cx);
choose("down down", cx);
assert!(!current(&desk, cx).contains("!["), "{}", current(&desk, cx));
}
#[gpui::test]
fn source_cuts_the_selection_a_right_press_keeps(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
switch(&desk, Mode::Source, cx);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
let line = cx.debug_bounds("editor-line-0").expect("drawn");
right_at(point(line.left() + px(20.0), line.top() + px(6.0)), cx);
choose("down", cx);
assert_eq!(current(&desk, cx), " beta\n");
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
}
#[gpui::test]
fn an_open_block_copies_its_selection_and_stays(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro words\n", cx);
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
cx.simulate_mouse_down(block.center(), MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(block.center(), MouseButton::Left, Modifiers::none());
cx.run_until_parked();
cx.simulate_keystrokes("home shift-right shift-right shift-right shift-right shift-right");
let field = cx.debug_bounds("markdown-open").expect("open");
right_at(point(field.left() + px(20.0), field.top() + px(14.0)), cx);
choose("down right", cx);
assert_eq!(clipboard(cx).as_deref(), Some("Intro"));
assert!(
cx.debug_bounds("markdown-open").is_some(),
"the block stays open"
);
assert_eq!(current(&desk, cx), "Intro words\n");
}
#[gpui::test]
fn preview_offers_copies_only(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha **beta**\n", cx);
switch(&desk, Mode::Preview, cx);
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
right_at(block.center(), cx);
choose("down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("alpha beta\n"));
right_at(block.center(), cx);
choose("down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("alpha **beta**\n"));
}
#[gpui::test]
fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("![pic](missing.png) and *it*\n", cx);
cx.dispatch_action(crate::export::CopyHtml);
cx.run_until_parked();
let html = clipboard(cx).expect("copied");
assert!(
html.contains("src=\"missing.png\"") && html.contains("<em>it</em>"),
"{html}"
);
}
#[gpui::test]
fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()));
// The context's dispatch parks; this one does not.
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
assert_eq!(
clipboard(cx).as_deref(),
Some("before"),
"not on the window thread"
);
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n"));
}
#[gpui::test]
fn a_later_markdown_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
cx.update(|window, cx| window.dispatch_action(Box::new(crate::clip::CopyMarkdown), cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha *it*\n"));
}
#[gpui::test]
fn a_later_editor_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
use gpui::Focusable as _;
let (desk, cx, _files) = opened("alpha *it*\n", cx);
switch(&desk, Mode::Source, cx);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
let copy = gpui::Keystroke::parse("secondary-c").expect("key");
cx.update(|window, cx| window.dispatch_keystroke(copy, cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
}
#[gpui::test]
fn a_later_rich_copy_with_the_same_text_outlives_a_pending_conversion(cx: &mut TestAppContext) {
use ely_platform::clipboard::{self, Clip};
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let rich = |html: &str| Clip::Html {
html: html.into(),
text: "x".into(),
};
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
clipboard::write(&rich("<b>x</b>")).expect("writes");
cx.write_to_clipboard(gpui::ClipboardItem::new_string("x".into()));
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
clipboard::write(&rich("<i>x</i>")).expect("writes");
cx.run_until_parked();
let html = clipboard::html().expect("reads");
assert_eq!(
clipboard(cx).as_deref(),
Some("x"),
"the conversion is dropped"
);
assert!(html.is_some_and(|html| html.contains("<i>x</i>")));
}
#[gpui::test]
fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) {
let (desk, cx, files) = opened("alpha\n", cx);
right_on(0, 1, cx);
assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened");
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut");
let other = files[1].path().join("b.md");
std::fs::write(&other, "beta\n").expect("b");
crate::desk::tests::open(&desk, &other, cx);
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
desk.update(cx, |desk, cx| {
desk.core.active = Some(first);
cx.notify();
});
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed");
}
+91
View File
@@ -0,0 +1,91 @@
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
use gpui::{
App, Entity, KeyUpEvent, Keystroke, Menu, Modifiers, TestAppContext, VisualTestContext, point,
px,
};
use super::tests::{current, document, opened, press};
use crate::{desk::Desk, view::Mode};
/// Selects `alpha` in the first block, opened.
fn select_alpha(desk: &Entity<Desk>, cx: &mut VisualTestContext) {
let bounds = cx.debug_bounds("markdown-block-0").expect("a block");
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
cx.simulate_click(at, Modifiers::none());
press(
"home shift-right shift-right shift-right shift-right shift-right",
cx,
);
let selected = document(desk, cx).read_with(cx, |document, cx| document.selected(cx));
assert_eq!(selected, "alpha");
}
/// Runs `menu`'s row `row` from the title bar.
fn choose(menu: fn(&App) -> Menu, row: usize, cx: &mut VisualTestContext) {
cx.update(|window, cx| {
cx.set_menus(vec![menu(cx)]);
window.refresh();
});
cx.run_until_parked();
let at = cx.update(|window, cx| {
let rem = window.rem_size();
let bar = cx.theme().titlebar_height().to_pixels(rem);
let title = cx.theme().control_height(ControlSize::Sm).to_pixels(rem);
point(px(12.), bar + title / 2.)
});
cx.simulate_click(at, Modifiers::none());
cx.run_until_parked();
press(&format!("{} enter", vec!["down"; row].join(" ")), cx);
let enter = Keystroke::parse("enter").expect("a key");
cx.simulate_event(KeyUpEvent { keystroke: enter });
cx.run_until_parked();
}
fn edit_menu(cx: &App) -> Menu {
let look = crate::look::Look::default();
crate::keys::menus(&look, cx).remove(0)
}
#[gpui::test]
fn a_menu_formats_the_open_blocks_selection(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
select_alpha(&desk, cx);
choose(super::format_menu, 1, cx);
assert_eq!(current(&desk, cx), "**alpha** beta\n");
}
#[gpui::test]
fn a_menu_copies_the_open_blocks_selection(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
select_alpha(&desk, cx);
// Copy as Markdown, after Copy as Plain Text.
choose(edit_menu, 2, cx);
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("alpha"));
}
const TABLED: &str = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
#[gpui::test]
fn delete_table_from_the_menu_is_one_undo_step(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened(TABLED, cx);
desk.update_in(cx, |desk, window, cx| desk.switch(Mode::Source, window, cx));
let document = document(&desk, cx);
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
choose(crate::tools::table_menu, 13, cx);
assert_eq!(current(&desk, cx), "before\n\nafter\n");
press("secondary-z", cx);
assert_eq!(current(&desk, cx), TABLED);
}
#[gpui::test]
fn delete_table_in_an_open_block_keeps_keyboard_undo(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened(TABLED, cx);
let bounds = cx.debug_bounds("markdown-block-1").expect("the table");
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
cx.simulate_click(at, Modifiers::none());
choose(crate::tools::table_menu, 13, cx);
assert_eq!(current(&desk, cx), "before\n\nafter\n");
press("secondary-z", cx);
assert_eq!(current(&desk, cx), TABLED);
}
+316
View File
@@ -0,0 +1,316 @@
use ely_gpui_component::documents::MarkdownDocument;
#[cfg(target_os = "windows")]
use gpui::Action;
use gpui::{
ClipboardItem, Entity, Focusable as _, Menu, MenuItem, TestAppContext, VisualTestContext,
};
use crate::{
desk::{
Desk,
tests::{open, start},
},
view::Mode,
};
pub(super) fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
(desk, cx, [data, files])
}
pub(super) fn document(
desk: &Entity<Desk>,
cx: &mut VisualTestContext,
) -> Entity<MarkdownDocument> {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().clone()
})
}
pub(super) fn current(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
let document = document(desk, cx);
document.read_with(cx, |document, cx| document.text(cx))
}
fn mode(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Mode {
desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a tab").read(cx).mode
})
}
/// `keys`, then the background reads.
pub(super) fn press(keys: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(keys);
cx.run_until_parked();
}
/// The window's toasts that hold `words`.
pub(super) fn toasted(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) -> usize {
let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window"));
let toasts = root.read_with(cx, |root, cx| root.toasts(cx));
toasts
.iter()
.filter(|(said, _)| said.contains(words))
.count()
}
#[gpui::test]
fn paragraph_keys_retype_the_caret_block(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
press("secondary-1", cx);
assert_eq!(current(&desk, cx), "# Intro\n");
press("secondary--", cx);
assert_eq!(current(&desk, cx), "## Intro\n");
press("secondary-=", cx);
assert_eq!(current(&desk, cx), "# Intro\n");
press("secondary-0", cx);
assert_eq!(current(&desk, cx), "Intro\n");
press("secondary-{", cx);
assert_eq!(current(&desk, cx), "1. Intro\n");
press("secondary-}", cx);
assert_eq!(current(&desk, cx), "- Intro\n");
press("secondary-shift-q", cx);
assert_eq!(
current(&desk, cx),
"- > Intro\n",
"the item's text is quoted"
);
}
#[gpui::test]
fn format_keys_wrap_the_caret_word(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("word\n", cx);
press("secondary-b", cx);
assert_eq!(current(&desk, cx), "**word**\n");
press("secondary-\\", cx);
assert_eq!(current(&desk, cx), "word\n");
press("alt-%", cx);
assert_eq!(current(&desk, cx), "~~word~~\n");
}
#[gpui::test]
fn ctrl_slash_toggles_source_inside_the_code_editor(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
press("secondary-/", cx);
assert_eq!(mode(&desk, cx), Mode::Source);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
cx.update(|window, cx| window.focus(&focus, cx));
press("secondary-/", cx);
assert_eq!(mode(&desk, cx), Mode::Visual);
assert_eq!(current(&desk, cx), "a\n", "no comment marks");
}
#[gpui::test]
fn a_refused_command_says_why(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("```\ncode\n```\n", cx);
press("secondary-1", cx);
assert_eq!(current(&desk, cx), "```\ncode\n```\n");
assert_eq!(toasted(&desk, "inside code", cx), 1);
}
#[gpui::test]
fn alt_arrows_move_a_table_row(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("| A | B |\n| - | - |\n| 1 | 2 |\n", cx);
press("secondary-/", cx);
document(&desk, cx).update(cx, |document, cx| document.set_caret(2, 2, cx));
press("alt-up", cx);
assert_eq!(
current(&desk, cx),
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
);
press("alt-up", cx);
assert_eq!(toasted(&desk, "Nothing lies past", cx), 1);
}
#[gpui::test]
fn view_keys_follow_typora(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
press("f8 f9 secondary-+", cx);
let look = desk.read_with(cx, |desk, _| desk.look.clone());
assert!(look.focus && look.typewriter && look.size > 100, "{look:?}");
press("secondary-)", cx);
assert_eq!(desk.read_with(cx, |desk, _| desk.look.size), 100);
}
#[gpui::test]
fn copy_as_markdown_and_paste_as_plain_text(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a **b**\n", cx);
press("secondary-shift-c", cx);
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("a **b**\n"));
cx.write_to_clipboard(ClipboardItem::new_string("*x*".into()));
press("secondary-/", cx);
press("secondary-shift-v", cx);
assert_eq!(current(&desk, cx), "*x*a **b**\n");
}
/// Row names; `-` a line, `▸` a submenu.
fn rows(menu: &Menu) -> Vec<String> {
let mut out = Vec::new();
for item in &menu.items {
match item {
MenuItem::Separator => out.push("-".into()),
MenuItem::Submenu(sub) => {
out.push(format!("▸ {}", sub.name));
out.extend(rows(sub).into_iter().map(|row| format!(" {row}")));
}
MenuItem::Action { name, .. } => out.push(name.to_string()),
MenuItem::SystemMenu(os) => out.push(os.name.to_string()),
}
}
out
}
#[gpui::test]
fn the_paragraph_and_format_menus_follow_typora(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
let menus = desk.read_with(cx, |desk, cx| crate::keys::menus(&desk.look, cx));
let named: Vec<String> = menus.iter().map(|menu| menu.name.to_string()).collect();
assert_eq!(named, ["Edit", "Paragraph", "Format", "View"]);
let table = [
"Insert Table",
"-",
"Add Row Above",
"Add Row Below",
"Add Column Before",
"Add Column After",
"-",
"Move Row Up",
"Move Row Down",
"Move Column Left",
"Move Column Right",
"-",
"Delete Row",
"Delete Column",
"-",
"Prettify Source Code",
"-",
"Delete Table",
];
let mut paragraph: Vec<String> = [
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Heading 5",
"Heading 6",
"-",
"Paragraph",
"-",
"Increase Heading Level",
"Decrease Heading Level",
"-",
"▸ Table",
]
.map(String::from)
.into();
paragraph.extend(table.map(|row| format!(" {row}")));
paragraph.extend(
[
"Math Block",
"Code Fences",
"-",
"Quote",
"-",
"Ordered List",
"Unordered List",
"Task List",
"-",
"Insert Paragraph Before",
"Insert Paragraph After",
"-",
"Link Reference",
"Footnotes",
"-",
"Horizontal Line",
"Table of Contents",
"YAML Front Matter",
]
.map(String::from),
);
assert_eq!(rows(&menus[1]), paragraph);
assert_eq!(
rows(&menus[2]).join("|"),
"Strong|Emphasis|Underline|Code|-|Strike|-|Hyperlink|Insert Image|-|Clear Format"
);
}
/// The keys a menu row shows for `action`.
#[cfg(target_os = "windows")]
fn keys(action: &dyn Action, cx: &mut VisualTestContext) -> String {
cx.update(|window, _| {
let binding = window
.highest_precedence_binding_for_action(action)
.expect("a binding");
let strokes: Vec<String> = binding
.keystrokes()
.iter()
.map(|key| key.unparse())
.collect();
strokes.join(" ")
})
}
#[cfg(target_os = "windows")]
#[gpui::test]
fn every_command_takes_typoras_windows_key(cx: &mut TestAppContext) {
use crate::{blocks::*, clip::*, keys::ToggleSource, look, tools::*};
let (_desk, cx, _files) = opened("a\n", cx);
let typora: [(&dyn Action, &str); 34] = [
(&Heading1, "ctrl-1"),
(&Heading6, "ctrl-6"),
(&Paragraph, "ctrl-0"),
(&Promote, "ctrl-="),
(&Demote, "ctrl--"),
(&MathBlock, "ctrl-shift-m"),
(&CodeFences, "ctrl-shift-k"),
(&Quote, "ctrl-shift-q"),
(&OrderedList, "ctrl-{"),
(&BulletList, "ctrl-}"),
(&TaskList, "ctrl-shift-x"),
(&Strong, "ctrl-b"),
(&Emphasis, "ctrl-i"),
(&Underline, "ctrl-u"),
(&InlineCode, "ctrl-~"),
(&Strike, "alt-%"),
(&Hyperlink, "ctrl-k"),
(&InsertImage, "ctrl-shift-i"),
(&ClearFormat, "ctrl-\\"),
(&InsertTable, "ctrl-t"),
(&RowBelow, "ctrl-enter"),
(&DeleteRow, "ctrl-shift-backspace"),
(&MoveRowUp, "alt-up"),
(&MoveRowDown, "alt-down"),
(&MoveColumnLeft, "alt-left"),
(&MoveColumnRight, "alt-right"),
(&ToggleSource, "ctrl-/"),
(&look::FocusMode, "f8"),
(&look::Typewriter, "f9"),
(&look::Larger, "ctrl-+"),
(&look::Smaller, "ctrl-_"),
(&look::ActualSize, "ctrl-("),
(&CopyMarkdown, "ctrl-shift-c"),
(&PastePlain, "ctrl-shift-v"),
];
for (action, want) in typora {
assert_eq!(keys(action, cx), want, "{}", action.name());
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Typora's Copy as Markdown and Paste as Plain Text.
use ely_gpui_component::primitives::Severity;
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
App, ClipboardItem, Context, Focusable as _, InteractiveElement as _, KeyBinding, Window,
actions,
};
use crate::desk::Desk;
actions!(mark, [CopyMarkdown, PastePlain]);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-shift-c", CopyMarkdown, at),
KeyBinding::new("secondary-shift-v", PastePlain, at),
]);
}
impl Desk {
/// Whether the document or nothing else holds focus.
fn on_document(&self, window: &Window, cx: &App) -> bool {
let Some(focused) = window.focused(cx) else {
return true;
};
let held = (self.active_view()).is_some_and(|view| {
let document = view.read(cx).document().read(cx);
document.focus_handle(cx).contains_focused(window, cx)
});
held || focused == self.core.focus
}
/// Hands a field outside the document its own `name` action.
fn to_field(name: &str, window: &mut Window, cx: &mut App) {
match cx.build_action(name, None) {
Ok(action) => window.dispatch_action(action, cx),
Err(error) => log::error!("mark: no action {name}: {error}"),
}
}
/// The selected Markdown, or the whole text, as written.
fn copy_markdown(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.on_document(window, cx) {
return Self::to_field("ely_input::Copy", window, cx);
}
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().read(cx);
let selected = document.selected(cx);
let markdown = match selected.is_empty() {
true => document.text(cx),
false => selected,
};
log::info!("mark: {} bytes copied as Markdown", markdown.len());
cx.write_to_clipboard(ClipboardItem::new_string(markdown));
}
/// The clipboard's text at the caret, pictures left out.
fn paste_plain(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.on_document(window, cx) {
return Self::to_field("ely_input::Paste", window, cx);
}
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else {
let said = text(cx, "mark.paste.none", &[]);
return self.toast(said, Severity::Info, cx);
};
let document = view.read(cx).document().clone();
if !document.update(cx, |document, cx| document.insert(&pasted, cx)) {
let said = text(cx, "mark.preview.read_only", &[]);
self.toast(said, Severity::Info, cx);
}
}
pub fn clip_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
root.on_action(
cx.listener(|desk, _: &CopyMarkdown, window, cx| desk.copy_markdown(window, cx)),
)
.on_action(cx.listener(|desk, _: &PastePlain, window, cx| desk.paste_plain(window, cx)))
}
}
+29 -31
View File
@@ -23,7 +23,6 @@ use gpui::{
};
use crate::engine::Mark;
use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode};
use crate::view::{MarkView, Mode};
pub struct Desk {
@@ -57,6 +56,8 @@ pub struct Desk {
asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
/// The copy being converted; a newer one drops it.
pub(crate) copying: Option<gpui::Task<()>>,
}
impl Host for Desk {
@@ -206,6 +207,7 @@ pub(crate) fn open_in(
asked: None,
asked_print: None,
finding: None,
copying: None,
}
});
let menus = crate::keys::menus(&look, cx);
@@ -225,7 +227,12 @@ pub(crate) fn open_in(
// Launch files stand alone; unsaved text still returns.
if asked.is_empty() {
for path in kept {
// A restored tab does not rejoin the recent list.
let (id, real) = (desk.core.next, dunce::canonicalize(&path).ok());
desk.open_path(path, cx);
if let Some(real) = real.filter(|_| desk.core.next > id) {
desk.remembered.insert(id, real);
}
}
}
for (at, name) in desk.keeping.untitled() {
@@ -334,7 +341,7 @@ impl Desk {
self.new_from_text("untitled.md", "", cx);
}
fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.switch(mode, window, cx));
}
@@ -417,35 +424,26 @@ impl Render for Desk {
.status(self.status(cx)),
}
.child(body);
let root =
div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(Self::open_picked))
.on_action(cx.listener(Self::on_new))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_find))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, window, cx| {
desk.switch(Mode::Visual, window, cx)
}))
.on_action(cx.listener(|desk, _: &SourceMode, window, cx| {
desk.switch(Mode::Source, window, cx)
}))
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
desk.switch(Mode::Preview, window, cx)
}))
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.compare_dialog(cx))
.children(self.find_box(cx))
.children(self.export_dialog(cx));
Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx)
let root = div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(Self::open_picked))
.on_action(cx.listener(Self::on_new))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_find))
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.compare_dialog(cx))
.children(self.find_box(cx))
.children(self.export_dialog(cx));
let root = Self::mode_actions(Self::look_actions(root, cx), cx);
let root = Self::block_actions(Self::table_actions(root, cx), cx);
Self::output_actions(Self::clip_actions(root, cx), cx)
}
}
+20
View File
@@ -82,6 +82,26 @@ fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppConte
assert!(!kept.contains(r"\\\\?\\"), "plain paths only: {kept}");
}
#[gpui::test]
fn the_recent_list_clears_from_its_button(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "Text.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
assert!(desk.read_with(cx, |desk, _| !desk.recent.is_empty()));
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
cx.simulate_click(button.center(), gpui::Modifiers::none());
cx.run_until_parked();
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
assert_eq!(kept, "[]");
assert!(cx.debug_bounds("mark-clear-recent").is_none(), "nothing left to clear");
}
#[gpui::test]
fn the_outline_follows_typing(cx: &mut TestAppContext) {
let (data, files) = (
+7 -2
View File
@@ -8,11 +8,11 @@ use std::{
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load};
use ely_office::{Engine, Host, Load, Tabs as _};
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::text::Source;
use crate::view::{Failed, MarkView, Reported};
use crate::view::{Failed, MarkView, Refused, Reported};
/// A file as read, and its text now.
pub struct Text {
@@ -137,6 +137,11 @@ impl Engine for Mark {
failed(host, &error.0, cx)
})
.detach();
cx.subscribe(view, |host, _, refused: &Refused, cx| {
let said = crate::blocks::refusal(refused.0, cx);
host.toast(said, ely_gpui_component::primitives::Severity::Info, cx)
})
.detach();
cx.subscribe(view, move |host, view, _: &Reported, cx| {
let modified = view.read(cx).modified();
reported(host, &view, modified, cx)
+136
View File
@@ -0,0 +1,136 @@
//! Rich, HTML code and plain text copies (R4.10).
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_office::prelude::*;
use ely_platform::clipboard::{self, Clip};
use ely_ui::i18n::text;
use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions};
use crate::desk::Desk;
use crate::output::{self, Page, Pictures};
actions!(mark, [CopyRich, CopyHtml, CopyPlain]);
/// What a copy turns the Markdown into.
#[derive(Clone, Copy)]
enum As {
Rich,
Html,
Plain,
}
/// A converted copy, ready for the clipboard.
enum Copied {
Rich(Clip, Page),
Html(String, Page),
Plain(String),
}
impl Desk {
pub(super) fn copy_actions(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx)))
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx)))
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx)))
}
/// The selection or whole text, and the file's folder.
fn copied_text(&mut self, cx: &mut Context<Self>) -> Option<(String, Option<PathBuf>)> {
let Some(view) = self.active_view() else {
self.no_document(cx);
return None;
};
let document = view.read(cx).document().read(cx);
let selected = document.selected(cx);
let markdown = match selected.is_empty() {
true => document.text(cx),
false => selected,
};
let beside = (self.active_tab())
.and_then(|tab| tab.session.path())
.and_then(|path| path.parent())
.map(PathBuf::from);
Some((markdown, beside))
}
fn copy_failed(&mut self, error: &std::io::Error, cx: &mut Context<Self>) {
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
/// Converts in the background, then writes the clipboard.
fn copy(&mut self, kind: As, cx: &mut Context<Self>) {
let Some((markdown, beside)) = self.copied_text(cx) else {
return;
};
let before = witness(cx);
self.copying = Some(cx.spawn(async move |desk, cx| {
let converted = cx
.background_executor()
.spawn(async move { convert(kind, markdown, beside) })
.await;
let landed = desk.update(cx, |desk, cx| {
desk.copying = None;
// A later copy, here or elsewhere, wins.
if witness(cx) != before {
return log::info!("mark: the clipboard changed first; a copy is dropped");
}
desk.copied(converted, cx);
});
if landed.is_err() {
log::warn!("mark: a copy finished after its window closed");
}
}));
}
fn copied(&mut self, converted: std::io::Result<Copied>, cx: &mut Context<Self>) {
let written = converted.and_then(|copied| match copied {
Copied::Rich(clip, page) => clipboard::write(&clip)
.map(|()| Some(page))
.map_err(|error| std::io::Error::other(format!("{error:#}"))),
Copied::Html(html, page) => {
log::info!("mark: {} bytes copied as HTML code", html.len());
cx.write_to_clipboard(ClipboardItem::new_string(html));
Ok(Some(page))
}
Copied::Plain(plain) => {
log::info!("mark: {} bytes copied as plain text", plain.len());
cx.write_to_clipboard(ClipboardItem::new_string(plain));
Ok(None)
}
});
match written {
Ok(Some(page)) => self.left_out(&page, cx),
Ok(None) => {}
Err(error) => self.copy_failed(&error, cx),
}
}
}
/// What the clipboard holds, its HTML included.
fn witness(cx: &mut Context<Desk>) -> (Option<ClipboardItem>, Result<Option<String>, String>) {
let html = clipboard::html().map_err(|error| {
log::warn!("mark: the clipboard's HTML did not read: {error:#}");
format!("{error:#}")
});
(cx.read_from_clipboard(), html)
}
/// The copy as `kind`, converted off the window thread.
fn convert(kind: As, markdown: String, beside: Option<PathBuf>) -> std::io::Result<Copied> {
let beside = beside.as_deref();
match kind {
As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| {
let html = std::mem::take(&mut page.html);
let clip = Clip::Html {
html,
text: markdown,
};
Copied::Rich(clip, page)
}),
As::Html => output::body(&markdown, beside, &Pictures::Written)
.map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)),
As::Plain => output::plain(&markdown).map(Copied::Plain),
}
}
+9 -41
View File
@@ -9,20 +9,20 @@ use ely_gpui_component::{
theme::ControlSize,
};
use ely_office::prelude::*;
use ely_platform::clipboard::{self, Clip};
use ely_ui::{
commands::{Export, Print},
i18n::text,
};
use gpui::{
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
actions,
};
use crate::desk::Desk;
use crate::output::{self, Format, Page, Pictures, Target};
use crate::output::{self, Format, Page, Target};
actions!(mark, [CopyRich]);
mod copy;
pub use copy::{CopyHtml, CopyPlain, CopyRich};
const FORMATS: [(Format, &str, &str); 4] = [
(Format::Page, "page", "html"),
@@ -41,12 +41,13 @@ fn key(format: Format) -> &'static str {
impl Desk {
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
let root = root
.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)));
Self::copy_actions(root, cx)
}
fn no_document(&self, cx: &mut Context<Self>) {
pub(crate) fn no_document(&self, cx: &mut Context<Self>) {
let said = text(cx, "command.blocked.no_document", &[]);
self.toast(said, Severity::Warning, cx);
}
@@ -275,39 +276,6 @@ impl Desk {
})
.detach_and_log_err(cx);
}
/// The selection, or the whole text, as HTML.
fn copy_rich(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().clone();
let selected = document.read(cx).selected(cx);
let markdown = match selected.is_empty() {
true => document.read(cx).text(cx),
false => selected,
};
let beside = self
.active_tab()
.and_then(|tab| tab.session.path())
.and_then(|path| path.parent());
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
let clip = Clip::Html {
html: std::mem::take(&mut page.html),
text: markdown,
};
clipboard::write(&clip)
.map(|()| page)
.map_err(|error| std::io::Error::other(format!("{error:#}")))
});
match copied {
Ok(page) => self.left_out(&page, cx),
Err(error) => {
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
}
/// A file of its own for each print job.
+2 -1
View File
@@ -25,7 +25,7 @@ fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
cx.run_until_parked();
let html = std::fs::read_to_string(&out).expect("written");
assert!(
html.contains("<h1>Note</h1>") && html.contains("<svg"),
html.contains("<h1 id=\"h-0\">Note</h1>") && html.contains("<svg"),
"{html}"
);
}
@@ -74,6 +74,7 @@ fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
});
cx.dispatch_action(crate::export::CopyRich);
cx.run_until_parked();
let clip = ely_platform::clipboard::read()
.expect("reads")
.expect("a clip");
+25 -2
View File
@@ -110,10 +110,11 @@ fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
assert!(current_text(&desk, cx).contains("| --- | --- | --- |"));
assert!(current_text(&desk, cx).contains("| ---- | ---- | ---- |"));
cx.dispatch_action(crate::tools::DeleteColumn);
cx.run_until_parked();
assert!(
current_text(&desk, cx).contains("| --- | --- |\n"),
current_text(&desk, cx).contains("| ---- | ---- |\n"),
"{}",
current_text(&desk, cx)
);
@@ -175,3 +176,25 @@ fn preview_keeps_its_text_from_tables_and_replacement(cx: &mut TestAppContext) {
replace_one(&desk, "x", cx);
assert_eq!(current_text(&desk, cx), "a\n");
}
#[gpui::test]
fn clipboard_keys_in_find_reach_the_field(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
query(&desk, "beta", cx);
let field = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().expect("open").query.clone()
});
field.update(cx, |field, cx| field.select(0..4, cx));
cx.simulate_keystrokes("secondary-shift-c");
cx.run_until_parked();
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("beta"));
cx.write_to_clipboard(gpui::ClipboardItem::new_string("alpha".into()));
cx.simulate_keystrokes("secondary-shift-v");
cx.run_until_parked();
assert_eq!(
field.read_with(cx, |field, _| field.text().to_string()),
"alpha"
);
assert_eq!(current_text(&desk, cx), "alpha beta\n");
}
+78 -31
View File
@@ -1,24 +1,34 @@
//! Modes, menus and keys (MARK-01, MARK-02).
//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15).
use ely_ui::{commands, i18n::text};
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
use crate::view::Mode;
use crate::{desk::Desk, view::Mode};
actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]);
actions!(
mark,
[VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk]
);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-1", VisualMode, at),
KeyBinding::new("secondary-2", SourceMode, at),
KeyBinding::new("secondary-3", PreviewMode, at),
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
KeyBinding::new("secondary-=", crate::look::Larger, at),
KeyBinding::new("secondary--", crate::look::Smaller, at),
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
KeyBinding::new("secondary-/", ToggleSource, at),
// Over the code editor's `//` toggle.
KeyBinding::new("secondary-/", ToggleSource, Some("ElyEditor")),
KeyBinding::new("f8", crate::look::FocusMode, at),
KeyBinding::new("f9", crate::look::Typewriter, at),
KeyBinding::new("secondary-+", crate::look::Larger, at),
KeyBinding::new("secondary-_", crate::look::Smaller, at),
KeyBinding::new("secondary-)", crate::look::ActualSize, at),
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
]);
// Windows' Chinese input keeps Ctrl+Shift+0 for itself.
#[cfg(target_os = "windows")]
cx.bind_keys([KeyBinding::new("ctrl-(", crate::look::ActualSize, at)]);
crate::blocks::bind(cx);
crate::tools::bind(cx);
crate::clip::bind(cx);
}
/// The label of `mode`.
@@ -26,36 +36,39 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
text(cx, &format!("mark.mode.{}", mode.name()), &[])
}
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
/// A menu row named by `key`, ticked when `checked`.
pub(crate) fn checked(key: &str, action: Box<dyn Action>, checked: bool, cx: &App) -> MenuItem {
MenuItem::Action {
name: text(cx, key, &[]),
action,
os_action: None,
checked,
disabled: false,
};
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
use crate::tools::{
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
};
}
}
pub(crate) fn item(key: &str, action: Box<dyn Action>, cx: &App) -> MenuItem {
checked(key, action, false, cx)
}
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn Action>, on: bool| checked(key, action, on, cx);
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
vec![
Menu::new(text(cx, "menu.edit", &[])).items([
item("office.find.open", Box::new(ely_office::FindReplace)),
item("mark.copy.plain", Box::new(crate::export::CopyPlain)),
item("mark.copy.markdown", Box::new(crate::clip::CopyMarkdown)),
item("mark.copy.html", Box::new(crate::export::CopyHtml)),
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
]),
Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
item("mark.paste.plain", Box::new(crate::clip::PastePlain)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
item("office.find.open", Box::new(ely_office::FindReplace)),
]),
crate::blocks::paragraph_menu(cx),
crate::blocks::format_menu(cx),
Menu::new(text(cx, "menu.view", &[])).items([
item("mark.view.source", Box::new(ToggleSource)),
MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode),
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
@@ -99,11 +112,45 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
look.font == Font::Mono,
),
MenuItem::separator(),
item("mark.look.actual", Box::new(look::ActualSize)),
item("mark.look.larger", Box::new(look::Larger)),
item("mark.look.smaller", Box::new(look::Smaller)),
item("mark.look.actual", Box::new(look::ActualSize)),
MenuItem::separator(),
item("mark.outside.check", Box::new(CheckDisk)),
]),
]
}
impl Desk {
/// Source and visual, as Typora's Source Code Mode.
fn toggle_source(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let mode = match view.read(cx).mode {
Mode::Source => Mode::Visual,
Mode::Visual | Mode::Preview => Mode::Source,
};
self.switch(mode, window, cx);
}
pub fn mode_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
root.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(
cx.listener(|desk, _: &ToggleSource, window, cx| desk.toggle_source(window, cx)),
)
.on_action(
cx.listener(|desk, _: &VisualMode, window, cx| {
desk.switch(Mode::Visual, window, cx)
}),
)
.on_action(
cx.listener(|desk, _: &SourceMode, window, cx| {
desk.switch(Mode::Source, window, cx)
}),
)
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
desk.switch(Mode::Preview, window, cx)
}))
}
}
+20 -7
View File
@@ -196,8 +196,8 @@ impl Desk {
mod tests {
use ely_gpui_component::documents::DocumentLook;
use gpui::{
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
point, rems,
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
rems,
};
use crate::desk::{
@@ -286,8 +286,11 @@ mod tests {
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "a
").expect("a file");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
@@ -300,12 +303,22 @@ mod tests {
..Default::default()
};
let other = match cfg!(target_os = "macos") {
true => Modifiers { control: true, ..Default::default() },
false => Modifiers { platform: true, ..Default::default() },
true => Modifiers {
control: true,
..Default::default()
},
false => Modifiers {
platform: true,
..Default::default()
},
};
cx.simulate_event(wheel(other, 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.0, "only Ctrl, or Cmd on macOS");
assert_eq!(
document_look(&desk, cx).scale,
1.0,
"only Ctrl, or Cmd on macOS"
);
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
+2
View File
@@ -3,6 +3,8 @@
mod attach;
mod autosave;
mod blocks;
mod clip;
mod compare;
mod desk;
mod engine;
+26
View File
@@ -0,0 +1,26 @@
//! Addresses, read as browsers read them.
/// A plain, web or mail address, read as browsers do.
pub(super) fn safe(url: &str) -> bool {
// Browsers trim spaces and controls, then drop tabs and newlines.
let read: String = url
.trim_matches(|ch: char| ch <= ' ')
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
match scheme(&read) {
None => true,
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
}
}
/// An address's scheme, when it has one.
pub(super) fn scheme(url: &str) -> Option<&str> {
let end = url.find(':')?;
let name = &url[..end];
let plain = name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
// A drive letter is a path, not a scheme.
(plain && name.len() > 1).then_some(name)
}
+358 -156
View File
@@ -1,84 +1,100 @@
//! A syntax tree written as HTML, node by node.
//! Markdown as HTML, as the drawing reads it.
use std::{collections::HashMap, io};
use std::{
cmp::Reverse,
collections::{HashSet, VecDeque},
io,
ops::Range,
};
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
use ely_gpui_component::{
documents::{
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
},
typography::formula_svg,
};
use markdown::mdast::{AlignKind, Node};
use super::{Page, attribute, escape, pictures::Placed};
use super::{Page, address::safe, attribute, escape, pictures::Placed};
/// Writes `tree` into `page`; pictures go through `placed`.
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
/// Writes `tree`, read from `source`, into `page`.
pub(super) fn write(
tree: &Node,
source: &str,
placed: &mut Placed,
page: &mut Page,
) -> io::Result<()> {
let mut writer = Writer {
placed,
page,
links: HashMap::new(),
notes: HashMap::new(),
called: Vec::new(),
source,
reader: MarkdownReader::joined(source, tree),
anchored: HashSet::new(),
};
writer.gather(tree);
writer.children(tree, false)?;
writer.footnotes()
writer.root(tree)
}
struct Writer<'a, 'b, 'c> {
placed: &'b mut Placed<'a>,
page: &'c mut Page,
/// Link definitions by identifier: address and title.
links: HashMap<String, (String, Option<String>)>,
notes: HashMap<String, Node>,
/// Footnotes in the order first called.
called: Vec<String>,
source: &'c str,
reader: MarkdownReader<'c>,
/// Notes whose definition has its anchor.
anchored: HashSet<usize>,
}
/// What a read area leaves out, in reading order.
#[derive(Default)]
struct Atoms {
/// Each formula's line.
formulas: VecDeque<usize>,
/// Each footnote reference's number.
notes: VecDeque<usize>,
}
/// A stretch of read text's mark.
enum Mark<'a> {
Style(Style),
Link(&'a str),
}
impl Writer<'_, '_, '_> {
fn gather(&mut self, node: &Node) {
match node {
Node::Definition(definition) => {
let target = (definition.url.clone(), definition.title.clone());
self.links
.entry(definition.identifier.clone())
.or_insert(target);
}
Node::FootnoteDefinition(note) => {
self.notes
.entry(note.identifier.clone())
.or_insert_with(|| node.clone());
}
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child);
}
}
fn push(&mut self, text: &str) {
self.page.html.push_str(text);
}
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.node(child, tight)?;
/// Root blocks: alerts and `[toc]` live only here.
fn root(&mut self, tree: &Node) -> io::Result<()> {
for child in tree.children().into_iter().flatten() {
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => self.alert(kind, &body)?,
None => self.block(child, false)?,
},
_ if is_toc(child, self.source) => self.toc(),
_ => self.block(child, false)?,
}
}
Ok(())
}
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
self.push(&format!("<{tag}>"));
self.children(node, false)?;
self.push(&format!("</{tag}>"));
fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.block(child, tight)?;
}
Ok(())
}
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
match node {
Node::Paragraph(_) if tight => self.children(node, false)?,
Node::Paragraph(_) => self.wrapped("p", node)?,
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
Node::Emphasis(_) => self.wrapped("em", node)?,
Node::Strong(_) => self.wrapped("strong", node)?,
Node::Delete(_) => self.wrapped("del", node)?,
Node::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => {
self.area(node, tight)?
}
Node::Blockquote(_) => {
self.push("<blockquote>");
self.blocks(node, false)?;
self.push("</blockquote>");
}
Node::List(list) => {
let tag = if list.ordered { "ol" } else { "ul" };
match list.start.filter(|start| list.ordered && *start != 1) {
@@ -92,73 +108,145 @@ impl Writer<'_, '_, '_> {
let mark = if checked { " checked" } else { "" };
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
}
self.children(item, !list.spread && !each.spread)?;
self.blocks(item, !list.spread && !each.spread)?;
self.push("</li>");
}
self.push(&format!("</{tag}>"));
}
Node::Table(table) => self.table(node, &table.align)?,
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
let line = line_of(node);
match diagram_svg(&code.value) {
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
Err(_) => {
self.page.diagrams.push(line);
self.page.diagrams.push(line_of(node));
self.code(Some("mermaid"), &code.value);
}
}
}
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
Node::Math(math) => self.math(node, &math.value, true),
Node::InlineMath(math) => self.math(node, &math.value, false),
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
Node::Text(text) => self.push(&escape(&text.value)),
// HTML in the document shows as text, never runs.
Node::Html(html) => self.push(&escape(&html.value)),
Node::Break(_) => self.push("<br>"),
Node::Math(math) => self.math(line_of(node), &math.value, true),
Node::ThematicBreak(_) => self.push("<hr>"),
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
Some((url, title)) => self.link(node, &url, title.as_deref())?,
None => self.children(node, false)?,
},
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
None => self.push(&escape(&image.alt)),
},
Node::FootnoteReference(call) => {
let number = match self.called.iter().position(|id| *id == call.identifier) {
Some(at) => at + 1,
None => {
self.called.push(call.identifier.clone());
self.called.len()
}
};
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
Node::FootnoteDefinition(note) => {
// A reference reaches its note's first definition.
let id = (self.reader.note(&note.identifier))
.filter(|number| self.anchored.insert(*number))
.map_or_else(String::new, |number| format!(" id=\"fn-{number}\""));
self.push(&format!(
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
escape(&self.reader.label(note))
));
self.blocks(node, false)?;
self.push("</div></div>");
}
// Written elsewhere, or never.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
_ => self.children(node, false)?,
// Never written.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
other => unreachable!("{other:?} is no block outside MDX"),
}
Ok(())
}
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
/// A paragraph, heading, table or HTML block, read as drawn.
fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> {
let mut read = Vec::new();
self.reader.block(node, &mut read);
if let Node::Table(_) = node {
let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else {
unreachable!("a table reads as one table");
};
return self.table(node, aligns, head, rows);
}
// A heading's pictures follow it whole.
let split = !matches!(node, Node::Heading(_));
let stretches = self.stretches(node, split);
self.run(&read, node, tight, split, stretches)
}
/// Pieces in order; with `split`, each picture ends a stretch.
fn run(
&mut self,
pieces: &[MarkdownBlock],
node: &Node,
tight: bool,
split: bool,
mut stretches: VecDeque<Atoms>,
) -> io::Result<()> {
let mut written = false;
for piece in pieces {
match piece {
MarkdownBlock::Image { alt, url, link, .. } => {
self.picture(url, alt, link.as_deref())?;
if split {
retire(stretches.pop_front(), written);
written = false;
}
}
_ => {
let atoms = stretches.front_mut().expect("a stretch per text");
self.piece(piece, node, tight, atoms);
written = true;
}
}
}
retire(stretches.pop_front(), written);
assert!(stretches.is_empty(), "a stretch per picture");
Ok(())
}
/// Formulas and notes by stretch; pictures part them.
fn stretches(&self, node: &Node, split: bool) -> VecDeque<Atoms> {
let mut out = VecDeque::from([Atoms::default()]);
self.gather(node, split, &mut out);
out
}
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
match node {
Node::InlineMath(_) => {
(out.back_mut().expect("a stretch").formulas).push_back(line_of(node))
}
Node::FootnoteReference(call) => {
let number = self
.reader
.note(&call.identifier)
.expect("a reference names a note");
out.back_mut().expect("a stretch").notes.push_back(number);
}
Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child, split, out);
}
}
fn table(
&mut self,
node: &Node,
aligns: &[AlignKind],
head: &[Vec<MarkdownBlock>],
rows: &[Vec<Vec<MarkdownBlock>>],
) -> io::Result<()> {
let written = node.children().expect("a table holds rows");
let read: Vec<&[Vec<MarkdownBlock>]> = std::iter::once(head)
.chain(rows.iter().map(Vec::as_slice))
.collect();
assert_eq!(read.len(), written.len(), "a read row per row");
self.push("<table>");
for (at, row) in node.children().into_iter().flatten().enumerate() {
let cell = if at == 0 { "th" } else { "td" };
for (at, (row, cells)) in read.into_iter().zip(written).enumerate() {
let tag = if at == 0 { "th" } else { "td" };
let cells = cells.children().expect("a row holds cells");
assert_eq!(row.len(), cells.len(), "a read cell per cell");
self.push("<tr>");
for (column, each) in row.children().into_iter().flatten().enumerate() {
let side = match align.get(column) {
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
let side = match aligns.get(column) {
Some(AlignKind::Left) => " style=\"text-align:left\"",
Some(AlignKind::Right) => " style=\"text-align:right\"",
Some(AlignKind::Center) => " style=\"text-align:center\"",
_ => "",
};
self.push(&format!("<{cell}{side}>"));
self.children(each, false)?;
self.push(&format!("</{cell}>"));
self.push(&format!("<{tag}{side}>"));
let stretches = self.stretches(cell, true);
self.run(pieces, cell, true, true, stretches)?;
self.push(&format!("</{tag}>"));
}
self.push("</tr>");
}
@@ -166,6 +254,113 @@ impl Writer<'_, '_, '_> {
Ok(())
}
fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) {
match piece {
MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms),
MarkdownBlock::Paragraph(text) => {
self.push("<p>");
self.inline(text, atoms);
self.push("</p>");
}
MarkdownBlock::Heading(depth, text) => {
let at = node.position().expect("a parsed node has its place");
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
self.inline(text, atoms);
self.push(&format!("</h{depth}>"));
}
MarkdownBlock::Math(tex) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, tex, true);
}
other => unreachable!("an area's text reads as text or math, not {other:?}"),
}
}
/// Read text; its marks open and close as a stack.
fn inline(&mut self, read: &Inline, atoms: &mut Atoms) {
let mut marks: Vec<(&Range<usize>, Mark)> = (read.styles.iter())
.filter(|(_, style)| *style != Style::Link)
.map(|(range, style)| (range, Mark::Style(*style)))
.chain(
read.links
.iter()
.map(|(range, url)| (range, Mark::Link(url))),
)
.collect();
// Outer first; a formula or note is its own text.
marks.sort_by_key(|(range, mark)| {
let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote));
(range.start, Reverse(range.end), whole)
});
let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0);
let mut links = 0;
for (range, mark) in marks {
links -= self.close_to(read, range.start, &mut open, &mut at);
assert!(
range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end),
"read marks nest"
);
self.text(&read.text[at..range.start]);
at = range.start;
match mark {
Mark::Style(Style::Math) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, &read.text[range.clone()], false);
at = range.end;
}
Mark::Style(Style::Footnote) => {
let number = atoms.notes.pop_front().expect("a note's number");
// Links never nest.
match links > 0 {
true => self.push(&format!("<sup>{number}</sup>")),
false => {
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
}
}
at = range.end;
}
Mark::Style(style) => {
let name = tag(style);
self.push(&format!("<{name}>"));
open.push((range.end, name));
}
Mark::Link(url) => {
match safe(url) {
true => self.push(&format!("<a href=\"{}\">", attribute(url))),
false => self.push("<a>"),
}
open.push((range.end, "a"));
links += 1;
}
}
}
self.close_to(read, read.text.len(), &mut open, &mut at);
self.text(&read.text[at..]);
}
/// Closes each mark ending by `to`; counts closed links.
fn close_to(
&mut self,
read: &Inline,
to: usize,
open: &mut Vec<(usize, &str)>,
at: &mut usize,
) -> usize {
let mut links = 0;
while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) {
self.text(&read.text[*at..end]);
self.push(&format!("</{name}>"));
*at = end;
open.pop();
links += usize::from(name == "a");
}
links
}
fn text(&mut self, text: &str) {
self.push(&escape(text).replace('\n', "<br>"));
}
fn code(&mut self, lang: Option<&str>, value: &str) {
let class = lang.map_or_else(String::new, |lang| {
format!(" class=\"language-{}\"", attribute(lang))
@@ -173,12 +368,12 @@ impl Writer<'_, '_, '_> {
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
}
fn math(&mut self, node: &Node, value: &str, display: bool) {
fn math(&mut self, line: usize, value: &str, display: bool) {
match (formula_svg(value, display), display) {
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
(Err(error), _) => {
self.page.formulas.push((line_of(node), error.to_string()));
self.page.formulas.push((line, error.to_string()));
match display {
true => self.code(Some("math"), value),
false => self.push(&format!("<code>{}</code>", escape(value))),
@@ -187,21 +382,37 @@ impl Writer<'_, '_, '_> {
}
}
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
let title = title.map_or_else(String::new, |title| {
format!(" title=\"{}\"", attribute(title))
});
// Only web, mail and in-page addresses follow.
match safe(url) {
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
false => self.push("<a>"),
/// A GitHub alert: its kind's title, then its blocks.
fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> {
let (title, hue) = (kind.title(), hue(kind));
self.push(&format!(
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
));
for node in body {
self.block(node, false)?;
}
self.children(node, false)?;
self.push("</a>");
self.push("</div>");
Ok(())
}
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
/// `[toc]`: each heading, linked to its place.
fn toc(&mut self) {
let items: String = (self.reader.headings.iter())
.map(|heading| {
format!(
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
heading.depth - 1,
heading.at,
escape(&heading.title)
)
})
.collect();
self.push(&format!(
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
));
}
fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> {
let src = self.placed.place(url)?;
if src.is_none() {
self.page.missing.push(if url.is_empty() {
@@ -215,36 +426,52 @@ impl Writer<'_, '_, '_> {
self.push(&format!("[{}]", escape(alt)));
return Ok(());
};
let title = title.map_or_else(String::new, |title| {
format!(" title=\"{}\"", attribute(title))
});
self.push(&format!(
"<img src=\"{}\" alt=\"{}\"{title}>",
let image = format!(
"<img src=\"{}\" alt=\"{}\">",
attribute(&src),
attribute(alt)
));
);
match link {
Some(link) if safe(link) => {
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
}
Some(_) => self.push(&format!("<a>{image}</a>")),
None => self.push(&image),
}
Ok(())
}
}
fn footnotes(&mut self) -> io::Result<()> {
if self.called.is_empty() {
return Ok(());
}
self.push("<section class=\"footnotes\"><hr><ol>");
// A note may call another note.
let mut at = 0;
while at < self.called.len() {
let note = self.notes.get(&self.called[at]).cloned();
at += 1;
self.push(&format!("<li id=\"fn-{at}\">"));
match note {
Some(note) => self.children(&note, false)?,
None => log::error!("mark: footnote {at} has no definition"),
}
self.push("</li>");
}
self.push("</ol></section>");
Ok(())
/// A stretch done: all read, or blank and dropped.
fn retire(atoms: Option<Atoms>, written: bool) {
let atoms = atoms.expect("a stretch per picture");
assert!(
atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()),
"every formula and note is read"
);
}
/// GitHub's color for an alert kind.
fn hue(kind: AlertKind) -> &'static str {
match kind {
AlertKind::Note => "#0969da",
AlertKind::Tip => "#1a7f37",
AlertKind::Important => "#8250df",
AlertKind::Warning => "#9a6700",
AlertKind::Caution => "#d1242f",
}
}
/// The tag a read style writes.
fn tag(style: Style) -> &'static str {
match style {
Style::Strong => "strong",
Style::Emphasis => "em",
Style::Strike => "del",
Style::Code => "code",
Style::Underline => "u",
Style::Mark => "mark",
Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"),
}
}
@@ -257,28 +484,3 @@ fn line_of(node: &Node) -> usize {
fn bare(svg: &str) -> &str {
svg.find("<svg").map_or(svg, |at| &svg[at..])
}
/// A plain, web or mail address, read as browsers do.
fn safe(url: &str) -> bool {
// Browsers trim spaces and controls, then drop tabs and newlines.
let read: String = url
.trim_matches(|ch: char| ch <= ' ')
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
match scheme(&read) {
None => true,
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
}
}
/// An address's scheme, when it has one.
pub(super) fn scheme(url: &str) -> Option<&str> {
let end = url.find(':')?;
let name = &url[..end];
let plain = name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
// A drive letter is a path, not a scheme.
(plain && name.len() > 1).then_some(name)
}
+3 -2
View File
@@ -1,5 +1,6 @@
//! Markdown as an HTML page or plain text (MARK-11).
mod address;
mod files;
mod html;
mod pictures;
@@ -12,7 +13,7 @@ use std::{io, path::Path};
use ely_gpui_component::documents::markdown_options;
pub use files::{Format, Target, write};
pub use pictures::Pictures;
pub use pictures::{Pictures, picture_file};
pub use plain::plain;
/// A page and what it could not carry.
@@ -45,7 +46,7 @@ fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let mut page = Page::default();
html::write(&tree, placed, &mut page)?;
html::write(&tree, text, placed, &mut page)?;
log::info!(
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
page.html.len(),
+27 -15
View File
@@ -8,7 +8,7 @@ use std::{
use base64::Engine as _;
use super::html::scheme;
use super::address::scheme;
/// Where pictures go.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -19,6 +19,8 @@ pub enum Pictures {
Folder(PathBuf),
/// Copied into this folder, in formats MuPDF reads.
Pdf(PathBuf),
/// Addresses as the text writes them.
Written,
}
/// Picture formats MuPDF reads from files.
@@ -40,6 +42,22 @@ pub(super) struct Placed<'a> {
pub made: bool,
}
/// A picture address's file; none for other schemes.
pub fn picture_file(url: &str, beside: Option<&Path>) -> Option<PathBuf> {
if url.to_lowercase().starts_with("file://") {
return Some(file_path(&url["file://".len()..]));
}
if scheme(url).is_some() {
return None;
}
let path = PathBuf::from(decoded(url));
match (path.is_absolute(), beside) {
(true, _) => Some(path),
(false, Some(beside)) => Some(beside.join(path)),
(false, None) => None,
}
}
impl<'a> Placed<'a> {
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
Self {
@@ -56,6 +74,9 @@ impl<'a> Placed<'a> {
/// The `src` for `url`; none when it is not carried.
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
if matches!(self.pictures, Pictures::Written) {
return Ok(Some(url.to_owned()));
}
let lower = url.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok((!self.pdf()).then(|| url.to_owned()));
@@ -67,20 +88,10 @@ impl<'a> Placed<'a> {
};
return Ok(readable.then(|| url.to_owned()));
}
let path = match (lower.starts_with("file://"), scheme(url)) {
(true, _) => file_path(&url["file://".len()..]),
// Other schemes are no picture of the document's.
(false, Some(_)) => return Ok(None),
(false, None) => {
let path = PathBuf::from(decoded(url));
match (path.is_absolute(), self.beside) {
(true, _) => path,
(false, Some(beside)) => beside.join(path),
(false, None) => return Ok(None),
}
}
};
self.carried(&path)
match picture_file(url, self.beside) {
Some(path) => self.carried(&path),
None => Ok(None),
}
}
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
@@ -98,6 +109,7 @@ impl<'a> Placed<'a> {
}
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
Pictures::Written => unreachable!("written addresses are not carried"),
};
let named = match self.copied.iter().find(|(from, _)| from == path) {
Some((_, name)) => name.clone(),
+57 -21
View File
@@ -2,21 +2,44 @@
use std::io;
use ely_gpui_component::documents::markdown_options;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
};
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let reader = MarkdownReader::joined(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
for child in tree.children().into_iter().flatten() {
block(child, &mut blocks);
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => {
blocks.push(kind.title().to_string());
for node in &body {
block(&reader, node, &mut blocks);
}
}
None => block(&reader, child, &mut blocks),
},
_ if is_toc(child, text) => {
let titles: Vec<&str> = (reader.headings.iter())
.map(|heading| heading.title.as_str())
.collect();
if !titles.is_empty() {
blocks.push(titles.join("\n"));
}
}
_ => block(&reader, child, &mut blocks),
}
}
Ok(blocks.join("\n\n") + "\n")
}
fn block(node: &Node, blocks: &mut Vec<String>) {
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
match node {
Node::Code(code) => blocks.push(code.value.clone()),
Node::Math(math) => blocks.push(math.value.clone()),
@@ -24,7 +47,7 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
let items = list.children.iter().map(|item| {
let mut inner = Vec::new();
for child in item.children().into_iter().flatten() {
block(child, &mut inner);
block(reader, child, &mut inner);
}
inner.join("\n")
});
@@ -32,32 +55,45 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
}
Node::Table(table) => {
let rows = table.children.iter().map(|row| {
let cells = row.children().into_iter().flatten().map(words);
let cells = row.children().into_iter().flatten();
let cells = cells.map(|cell| words(reader, cell));
cells.collect::<Vec<_>>().join("\t")
});
blocks.push(rows.collect::<Vec<_>>().join("\n"));
}
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
Node::FootnoteDefinition(note) => {
let mut body = Vec::new();
for child in &note.children {
block(reader, child, &mut body);
}
// The label the drawing shows beside it.
let label = format!("[{}]", reader.label(note));
blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string());
}
Node::Blockquote(_) => {
for child in node.children().into_iter().flatten() {
block(child, blocks);
block(reader, child, blocks);
}
}
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
// Front matter, HTML, rules and definitions are not words.
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
for piece in read {
match piece {
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
other => unreachable!("an HTML block reads as text, not {other:?}"),
}
}
}
// Front matter, rules and definitions are not words.
_ => {}
}
}
/// The words of an inline run.
fn words(node: &Node) -> String {
match node {
Node::Text(text) => text.value.clone(),
Node::InlineCode(code) => code.value.clone(),
Node::InlineMath(math) => math.value.clone(),
Node::Break(_) => "\n".into(),
Node::Image(image) => image.alt.clone(),
Node::ImageReference(image) => image.alt.clone(),
Node::Html(_) => String::new(),
_ => node.children().into_iter().flatten().map(words).collect(),
}
/// An inline run's words, as the drawing reads them.
fn words(reader: &MarkdownReader, node: &Node) -> String {
reader
.inline(node.children().expect("an inline run holds inlines"))
.text
}
+13 -5
View File
@@ -2,6 +2,8 @@ use std::path::Path;
use super::{Pictures, body, document, plain};
mod visual;
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
body(text, beside, pictures).expect("a page")
}
@@ -51,17 +53,17 @@ fn markdown_writes_as_html() {
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
let html = page(text, None, &Pictures::Inline).html;
for part in [
"<h1>T</h1>",
"<h1 id=\"h-0\">T</h1>",
"<li><input type=\"checkbox\" disabled checked> done</li>",
"<ol start=\"3\">",
"<th style=\"text-align:left\">l</th>",
"<td style=\"text-align:right\">2</td>",
"<del>gone</del>",
"<a href=\"https://x.org\" title=\"t\">a</a>",
"<a href=\"https://x.org\">a</a>",
"<a href=\"/docs\">r</a>",
"<a>bad</a>",
"<sup><a href=\"#fn-1\">1</a></sup>",
"<li id=\"fn-1\"><p>The note.</p></li>",
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
] {
assert!(html.contains(part), "{part} in {html}");
}
@@ -458,9 +460,15 @@ fn a_formula_letter_no_font_has_stays_as_tex() {
fn a_large_document_exports_whole() {
// Long paragraphs: markdown-rs slows with the block count.
let words = "word ".repeat(200);
let mut text: String = (0..2_200).map(|n| format!("Paragraph {n}: {words}\n\n")).collect();
let mut text: String = (0..2_200)
.map(|n| format!("Paragraph {n}: {words}\n\n"))
.collect();
text.push_str("The last line.\n");
assert!(text.len() > ely_gpui_component::documents::LARGEST_READ);
assert!(plain(&text).expect("text").ends_with("The last line.\n"));
assert!(page(&text, None, &Pictures::Inline).html.ends_with("<p>The last line.</p>"));
assert!(
page(&text, None, &Pictures::Inline)
.html
.ends_with("<p>The last line.</p>")
);
}
+239
View File
@@ -0,0 +1,239 @@
//! Exports read the file as the visual mode does (R4.4).
use super::{Pictures, page, plain};
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
fn html(text: &str) -> String {
page(text, None, &Pictures::Inline).html
}
fn text(text: &str) -> String {
plain(text).expect("plain text")
}
#[test]
fn shortcodes_turn_as_github_turns_them() {
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
for part in [
"<p>Go 😄 x😄</p>",
"<p>x:smile: :smile:</p>",
"<p>a <code>:smile:</code> 😄</p>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert_eq!(
text("Go :smile:\n\nx:smile: :smile:\n"),
"Go 😄\n\nx:smile: :smile:\n"
);
}
#[test]
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
let page = html(source);
for part in [
"<u>u</u>",
"<mark>m</mark>",
"<code>k</code>",
" s ",
"&lt;script&gt;x&lt;/script&gt;",
"<a>j</a>",
"<a href=\"https://e.test\">e</a>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert!(
!page.contains("<span") && !page.contains("<script"),
"{page}"
);
assert_eq!(
text("a <b>b</b> <script>x</script>\n"),
"a b <script>x</script>\n"
);
}
#[test]
fn an_html_block_shows_its_text() {
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
let page = html(source);
assert!(
page.contains("<p><strong>Bold</strong> and 😄</p>"),
"{page}"
);
assert!(!page.contains("<div"), "{page}");
assert_eq!(text(source), "Bold and 😄\n");
}
#[test]
fn an_alert_writes_its_kind_and_title() {
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
assert!(page.contains(alert), "{page}");
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
}
#[test]
fn a_toc_lists_the_headings_and_links_them() {
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
assert!(page.contains(toc), "{page}");
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
}
#[test]
fn tags_crossing_containers_read_as_drawn() {
for (source, shown) in [
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
("a <code>*b</code>* :smile:\n", "a b 😄"),
] {
assert_eq!(text(source), format!("{shown}\n"));
let page = html(source);
assert_eq!(page.matches("😄").count(), 1, "{page}");
assert_eq!(
page.matches(":smile:").count(),
shown.matches(":smile:").count(),
"{page}"
);
}
}
#[test]
fn each_cell_and_block_reads_on_its_own() {
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
assert_eq!(page.matches("😄").count(), 3, "{page}");
let page = html("# a <code>x\n\n:smile:\n");
assert!(page.contains("<p>😄</p>"), "{page}");
}
#[test]
fn a_block_closes_the_tags_left_open_in_it() {
let page = html("a <b>bold\n\nnormal\n");
assert!(
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
"{page}"
);
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
assert!(page.ends_with("<p>normal</p>"), "{page}");
}
#[test]
fn every_toc_entry_has_its_heading() {
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
let targets: Vec<&str> = page
.split("href=\"#")
.skip(1)
.map(|rest| &rest[..rest.find('"').expect("a quote")])
.collect();
assert_eq!(targets.len(), 2, "{page}");
for target in targets {
assert!(
page.contains(&format!("id=\"{target}\"")),
"{target} in {page}"
);
}
}
#[test]
fn a_long_html_block_writes_in_linear_time() {
let took = |count: usize| {
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
let start = std::time::Instant::now();
html(&source);
start.elapsed()
};
let (small, large) = (took(8_000), took(32_000));
// Four times the input; squared would be sixteen.
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn notes_under_deep_marks_write_in_linear_time() {
let took = |count: usize| {
let source = format!(
"start {}{}
[^n]: note
",
"<b>".repeat(count),
"x[^n] ".repeat(count)
);
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
let mut placed = Placed::new(None, &Pictures::Inline);
let start = std::time::Instant::now();
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
start.elapsed()
};
// Parsing grows squared upstream; untimed.
let (small, large) = (took(4_000), took(16_000));
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn a_blank_formula_is_dropped_with_its_text() {
for source in [
"$ $\n",
"$ $ ![p](p.png)\n",
"| $ $ | a |\n| - | - |\n| b | c |\n",
] {
html(source);
}
let page = page("$ $ ![p](p.png)\nnext $\\sqrt{$\n", None, &Pictures::Inline);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [2], "{}", page.html);
}
#[test]
fn a_note_in_a_link_keeps_the_link_whole() {
let page = html("a <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
assert!(
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
"{page}"
);
}
#[test]
fn a_reference_reaches_its_own_note() {
let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n");
assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}");
let at = page.find("id=\"fn-1\"").expect("an anchor");
assert!(
page[at..].contains("right target") && !page[at..].contains("unused"),
"{page}"
);
}
#[test]
fn plain_notes_keep_the_labels_drawn_beside_them() {
assert_eq!(
text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"),
"A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n"
);
assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n");
}
#[test]
fn a_copied_fragment_carries_its_look() {
let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n");
for part in [
"border-left:.25em solid #0969da",
"font-weight:600",
"list-style:none",
"display:flex",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert!(
!page.contains("class="),
"the look needs no page style: {page}"
);
}
#[test]
fn a_soft_break_joins_in_exports_as_typora_exports_it() {
let page = html("你好\n测试 \nend\n");
assert!(page.contains("<p>你好 测试<br>end</p>"), "{page}");
assert_eq!(text("你好\n测试\n"), "你好 测试\n");
}
+11 -2
View File
@@ -293,7 +293,13 @@ impl Desk {
}
/// A new file with the saved text; edits stay.
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
fn keep_edits(
&mut self,
id: u64,
disk: Source,
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab_mut(id) else {
return;
};
@@ -398,7 +404,10 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
let (sender, mut changes) = mpsc::unbounded();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
// Reads, ours included, are no change.
if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) {
if event
.as_ref()
.is_ok_and(|event| ely_ui::watching::only_read(&event.kind))
{
return;
}
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
+24 -3
View File
@@ -13,9 +13,14 @@ use crate::folder::{Folder, markdown_under};
impl Desk {
/// A folder lists beside; a file opens in a tab.
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
match path.is_dir() {
true => self.open_folder(path, cx),
false => self.open_file(path, cx),
if path.is_dir() {
return self.open_folder(path, cx);
}
// A shown file only takes focus; the list still learns.
let shown = self.core.sessions.is_open(&path).unwrap_or(false);
self.open_file(path.clone(), cx);
if shown {
self.remember(&path, cx);
}
}
@@ -108,6 +113,22 @@ impl Desk {
}
}
/// Empties the recent list.
pub fn clear_recent(&mut self, cx: &mut Context<Self>) {
match ely_files::recent::clear(&self.recent_file) {
Ok(()) => {
log::info!("mark: the recent list is cleared");
self.recent.clear();
cx.notify();
}
Err(error) => {
log::error!("mark: the recent list did not clear: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// Line and column `at` in a tab or file.
pub fn open_at(
&mut self,
+23 -10
View File
@@ -141,8 +141,11 @@ impl Keeping {
/// The untitled tabs to make: their place here and name.
pub fn untitled(&self) -> Vec<(usize, String)> {
let waiting = self.waiting.iter().enumerate();
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
let untitled =
waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
untitled
.map(|(at, waiting)| (at, waiting.kept.name.clone()))
.collect()
}
/// Entry `at` belongs to untitled tab `tab`.
@@ -152,11 +155,14 @@ impl Keeping {
/// What tab `id` takes: its file or its copy.
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
let at = self.waiting.iter().position(|waiting| match (&waiting.real, real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => waiting.tab == Some(id),
_ => false,
})?;
let at = self
.waiting
.iter()
.position(|waiting| match (&waiting.real, real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => waiting.tab == Some(id),
_ => false,
})?;
Some(self.waiting.remove(at))
}
@@ -243,7 +249,9 @@ impl Desk {
// Modes take focus: in the window, after this update.
cx.spawn(async move |desk, cx| {
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, window, cx))
view.update(cx, |view, cx| {
view.reopen(mode, kept.place, back, window, cx)
})
});
if let Err(error) = shown {
log::warn!("mark: tab {id} did not restore: {error:#}");
@@ -285,7 +293,8 @@ impl Desk {
.spawn(async move {
let now = SystemTime::now();
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
let mut recovery = Recovery::new(folder)
.with_limits(limits.keep, limits.megabytes * 1024 * 1024);
recovery.write_once(current.as_bytes(), None, now)
})
.await;
@@ -350,7 +359,11 @@ impl Desk {
None => text(cx, "office.stopped.never", &[]).to_string(),
};
let name = tab.name.to_string();
let said = text(cx, "mark.recovery.failed", &[("name", &name), ("error", &error), ("at", &at)]);
let said = text(
cx,
"mark.recovery.failed",
&[("name", &name), ("error", &error), ("at", &at)],
);
self.toast_unlogged(said, ely_gpui_component::primitives::Severity::Danger, cx);
}
+46
View File
@@ -441,4 +441,50 @@ mod window {
"the session"
);
}
#[gpui::test]
fn a_cleared_recent_list_stays_cleared_with_tabs_restored(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
std::fs::write(&a, "A
").expect("a");
std::fs::write(&b, "B
").expect("b");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
cx.simulate_click(button.center(), gpui::Modifiers::none());
cx.run_until_parked();
desk.update(cx, |desk, cx| {
desk.leave(cx);
desk.keeping.close();
});
cx.run_until_parked();
let (desk, cx) = start(second, data.path());
assert_eq!(names(&desk, cx), ["a.md"]);
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
let list = data.path().join("recent.json");
assert_eq!(std::fs::read_to_string(&list).expect("the list"), "[]");
// Explicit opens still join it: the restored file, a link.
let alias = files.path().join("alias.md");
std::fs::hard_link(&a, &alias).expect("a hard link");
open(&desk, &a, cx);
open(&desk, &alias, cx);
open(&desk, &b, cx);
assert_eq!(names(&desk, cx), ["a.md", "b.md"]);
let real = |path: &std::path::Path| dunce::canonicalize(path).expect("real");
let want = [real(&b), real(&alias), real(&a)];
assert_eq!(desk.read_with(cx, |desk, _| desk.recent.clone()), want);
let kept: Vec<std::path::PathBuf> =
serde_json::from_str(&std::fs::read_to_string(&list).expect("the list"))
.expect("a list");
assert_eq!(kept, want);
}
}
+65 -25
View File
@@ -15,8 +15,7 @@ use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Render,
SharedString, Styled as _, WeakEntity, Window, div,
ParentElement as _, Render, SharedString, Styled as _, WeakEntity, Window, div,
};
use crate::desk::Desk;
@@ -201,7 +200,7 @@ impl Side {
.action(open)
.into_any_element();
}
let opener = desk.downgrade();
let (opener, clearer) = (desk.downgrade(), desk.downgrade());
let rows = read.recent.iter().map(|path| {
let name = path
.file_name()
@@ -215,15 +214,32 @@ impl Side {
);
(key, item)
});
rows.fold(
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|list, (key, item)| list.row(key, item),
)
.on_activate(move |key, _, cx| {
let path = PathBuf::from(key.as_ref());
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
})
.into_any_element()
let list = rows
.fold(
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|list, (key, item)| list.row(key, item),
)
.on_activate(move |key, _, cx| {
let path = PathBuf::from(key.as_ref());
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
});
let clear = Button::new("mark-clear-recent", text(cx, "mark.recent.clear", &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
clearer.update(cx, |desk, cx| desk.clear_recent(cx)).ok();
});
div()
.flex()
.flex_col()
.gap_2()
.size_full()
.child(div().flex_1().min_h_0().child(list))
.child(
div()
.debug_selector(|| "mark-clear-recent".into())
.child(clear),
)
.into_any_element()
}
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
@@ -251,17 +267,21 @@ impl Side {
},
);
let outline = outline.on_select(move |key, window, cx| {
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
return log::error!("mark: the outline chose no heading");
};
// The heading in view, the caret on it.
let line = heading.line;
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
});
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
return log::error!("mark: the outline chose no heading");
};
// The heading in view, the caret on it.
let line = heading.line;
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
});
ScrollArea::new("mark-outline-scroll")
.vertical()
.size_full()
.child(div().debug_selector(|| "mark-outline".into()).child(outline))
.child(
div()
.debug_selector(|| "mark-outline".into())
.child(outline),
)
.into_any_element()
}
@@ -269,9 +289,17 @@ impl Side {
let this = cx.entity();
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
.size(ControlSize::Sm)
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
.segment(
"file",
text(cx, "mark.search.file", &[]),
Some(IconName::File),
);
let scope = match desk.read(cx).folder {
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
Some(_) => scope.segment(
"folder",
text(cx, "mark.search.folder", &[]),
Some(IconName::Folder),
),
None => scope,
};
let scope = scope.on_change(move |key, _, cx| {
@@ -350,9 +378,21 @@ impl Render for Side {
};
let panes = SegmentedControl::new("mark-pane", key)
.size(ControlSize::Sm)
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
.segment(
"files",
text(cx, "mark.pane.files", &[]),
Some(IconName::Folder),
)
.segment(
"outline",
text(cx, "mark.pane.outline", &[]),
Some(IconName::List),
)
.segment(
"search",
text(cx, "mark.pane.search", &[]),
Some(IconName::Search),
)
.on_change(move |key, _, cx| {
this.update(cx, |side, cx| {
side.pane = match key.as_ref() {
+69 -56
View File
@@ -1,14 +1,10 @@
//! The table menu's work on the shown file (MARK-06).
use ely_gpui_component::{
documents::{TableEdit, TableRefusal},
primitives::Severity,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{Context, InteractiveElement as _, actions};
use ely_gpui_component::documents::TableEdit;
use ely_ui::{commands, i18n::text};
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
use crate::desk::Desk;
use crate::{blocks::edits, desk::Desk, keys::item};
actions!(
mark,
@@ -18,58 +14,75 @@ actions!(
RowBelow,
ColumnLeft,
ColumnRight,
MoveRowUp,
MoveRowDown,
MoveColumnLeft,
MoveColumnRight,
DeleteRow,
DeleteColumn
DeleteColumn,
TidyTable,
DeleteTable
]
);
impl Desk {
fn table(&mut self, edit: Option<TableEdit>, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
let document = view.read(cx).document().clone();
let done = document.update(cx, |document, cx| match edit {
Some(edit) => document.edit_table(edit, cx),
None => document.insert_table(cx),
});
let Err(refusal) = done else {
return;
};
let key = match refusal {
TableRefusal::Outside => "mark.table.none",
TableRefusal::Kept => "mark.table.kept",
TableRefusal::Beyond => "mark.table.beyond",
TableRefusal::Mixed => "mark.table.mixed",
TableRefusal::Preview => "mark.preview.read_only",
};
let said = text(cx, key, &[]);
self.toast(said, Severity::Info, cx);
}
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
// An open block's input takes Ctrl+Enter as Submit.
let open = Some("MarkdownDocument > ElyInput");
cx.bind_keys([
KeyBinding::new("secondary-t", InsertTable, at),
KeyBinding::new("secondary-enter", RowBelow, at),
KeyBinding::new("secondary-enter", RowBelow, open),
KeyBinding::new("alt-up", MoveRowUp, at),
KeyBinding::new("alt-down", MoveRowDown, at),
KeyBinding::new("alt-left", MoveColumnLeft, at),
KeyBinding::new("alt-right", MoveColumnRight, at),
KeyBinding::new("secondary-shift-backspace", DeleteRow, at),
]);
}
pub fn table_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
element
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx)))
.on_action(
cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)),
)
.on_action(
cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)),
)
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| {
desk.table(Some(TableEdit::ColumnLeft), cx)
}))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| {
desk.table(Some(TableEdit::ColumnRight), cx)
}))
.on_action(
cx.listener(|desk, _: &DeleteRow, _, cx| {
desk.table(Some(TableEdit::DeleteRow), cx)
}),
)
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| {
desk.table(Some(TableEdit::DeleteColumn), cx)
}))
/// Typora's Paragraph ▸ Table.
pub fn table_menu(cx: &App) -> Menu {
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.move_row_up", Box::new(MoveRowUp)),
item("mark.table.move_row_down", Box::new(MoveRowDown)),
item("mark.table.move_column_left", Box::new(MoveColumnLeft)),
item("mark.table.move_column_right", Box::new(MoveColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
MenuItem::separator(),
item("mark.table.tidy", Box::new(TidyTable)),
MenuItem::separator(),
item("mark.table.delete", Box::new(DeleteTable)),
])
}
impl Desk {
pub fn table_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use TableEdit as T;
edits!(root, cx,
InsertTable => |d, cx| d.insert_table(cx),
RowAbove => |d, cx| d.edit_table(T::RowAbove, cx),
RowBelow => |d, cx| d.edit_table(T::RowBelow, cx),
ColumnLeft => |d, cx| d.edit_table(T::ColumnLeft, cx),
ColumnRight => |d, cx| d.edit_table(T::ColumnRight, cx),
MoveRowUp => |d, cx| d.edit_table(T::MoveRowUp, cx),
MoveRowDown => |d, cx| d.edit_table(T::MoveRowDown, cx),
MoveColumnLeft => |d, cx| d.edit_table(T::MoveColumnLeft, cx),
MoveColumnRight => |d, cx| d.edit_table(T::MoveColumnRight, cx),
DeleteRow => |d, cx| d.edit_table(T::DeleteRow, cx),
DeleteColumn => |d, cx| d.edit_table(T::DeleteColumn, cx),
TidyTable => |d, cx| d.edit_table(T::Tidy, cx),
DeleteTable => |d, cx| d.edit_table(T::Delete, cx),
)
}
}
+98 -24
View File
@@ -1,13 +1,10 @@
//! Whether Git sees the file changed; ElyTree does the rest.
use std::{
io,
path::Path,
};
use std::{io, path::Path};
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
use ely_office::prelude::*;
use ely_platform::AppId;
use ely_platform::{AppId, git::NoGit};
use ely_ui::i18n::text;
use gpui::Context;
@@ -23,15 +20,22 @@ pub enum Tracked {
Untracked,
Ignored,
Conflicted,
/// Ely's Git sits beside ElyTree, which is not installed.
NoGit,
/// Git could not answer; why.
Unknown(String),
}
/// Git's word on `file`: in a repository, then its status.
pub fn ask(file: &Path) -> Tracked {
match status(file) {
Ok(tracked) => tracked,
Err(error) => Tracked::Unknown(error.to_string()),
status(file).unwrap_or_else(failed)
}
/// A failure: no Git at all, else why.
fn failed(error: io::Error) -> Tracked {
match error.get_ref().is_some_and(|inner| inner.is::<NoGit>()) {
true => Tracked::NoGit,
false => Tracked::Unknown(error.to_string()),
}
}
@@ -122,6 +126,16 @@ impl Desk {
Tracked::Untracked => "mark.git.untracked",
Tracked::Ignored => "mark.git.ignored",
Tracked::Conflicted => "mark.git.conflicted",
Tracked::NoGit => {
let this = cx.entity();
let item = StatusBarItem::new("mark-git")
.label(text(cx, "mark.git.none", &[]))
.tooltip(text(cx, "mark.git.needs", &[]));
return Some(item.on_click(move |_, _, cx| {
let said = text(cx, "mark.git.needs", &[]);
this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx));
}));
}
Tracked::Unknown(why) => {
let label = text(cx, "mark.git.unknown", &[]);
return Some(
@@ -145,7 +159,10 @@ impl Desk {
}
let (this, file) = (this.clone(), file.clone());
cx.spawn(async move |cx| {
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
let opened = cx
.background_executor()
.spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) })
.await;
if let Err(error) = opened {
log::error!("mark: ElyTree did not open: {error}");
cx.update(|cx| {
@@ -172,11 +189,21 @@ mod tests {
assert!(done.expect("git runs").status.success(), "git {args:?}");
}
#[test]
fn no_git_asks_for_elytree_and_other_failures_say_why() {
let missing = io::Error::new(io::ErrorKind::NotFound, NoGit);
assert_eq!(failed(missing), Tracked::NoGit);
let other = io::Error::new(io::ErrorKind::NotFound, "gone");
assert_eq!(failed(other), Tracked::Unknown("gone".into()));
}
#[test]
fn spaces_stay_and_broken_repositories_say_why() {
let dir = tempfile::tempdir().expect("a temp dir");
// Windows names end in no space.
let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " });
let repo = dir
.path()
.join(if cfg!(windows) { "re po" } else { "repo " });
std::fs::create_dir(&repo).expect("the folder");
git(&repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a").expect("a file");
@@ -193,11 +220,28 @@ mod tests {
let dir = tempfile::tempdir().expect("a temp dir");
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join(".gitignore"), "skip.md
").expect("ignores");
std::fs::write(
repo.join(".gitignore"),
"skip.md
",
)
.expect("ignores");
std::fs::write(repo.join("t.md"), "a").expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
git(
repo,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"one",
],
);
assert_eq!(ask(&repo.join("t.md")), Tracked::Unchanged);
std::fs::write(repo.join("t.md"), "b").expect("an edit");
assert_eq!(ask(&repo.join("t.md")), Tracked::Changed);
@@ -208,30 +252,60 @@ mod tests {
#[gpui::test]
fn the_status_item_follows_a_save(cx: &mut gpui::TestAppContext) {
use ely_office::prelude::*;
let (data, dir) = (tempfile::tempdir().expect("data"), tempfile::tempdir().expect("repo"));
let (data, dir) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("repo"),
);
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a
").expect("a file");
std::fs::write(
repo.join("t.md"),
"a
",
)
.expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
git(
repo,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
"commit",
"-qm",
"one",
],
);
let (desk, cx) = crate::desk::tests::start(cx, data.path());
crate::desk::tests::open(&desk, &repo.join("t.md"), cx);
let said = |cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
let said =
|cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
assert_eq!(said(cx), Some(Tracked::Unchanged));
let (id, view) = desk.read_with(cx, |desk, _| {
let tab = desk.active_tab().expect("a tab");
(tab.id, tab.view.clone())
});
view.update(cx, |view, cx| view.adopt("b
".into(), cx));
cx.executor().advance_clock(std::time::Duration::from_secs(1));
view.update(cx, |view, cx| {
view.adopt(
"b
"
.into(),
cx,
)
});
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update_in(cx, |desk, window, cx| desk.save(id, None, window, cx));
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(repo.join("t.md")).expect("saved"), "b
");
assert_eq!(
std::fs::read_to_string(repo.join("t.md")).expect("saved"),
"b
"
);
assert_eq!(said(cx), Some(Tracked::Changed));
}
}
+26 -3
View File
@@ -2,10 +2,13 @@
use std::path::PathBuf;
use ely_gpui_component::documents::{DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode};
use ely_gpui_component::documents::{
DocumentEvent, EditRefusal, LARGEST_READ, MarkdownDocument, MarkdownMode,
};
use gpui::{
AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _,
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
Window, div,
};
use crate::engine::{Doc, mark};
@@ -16,6 +19,9 @@ pub struct Reported;
/// What went wrong, for the shell to say.
pub struct Failed(pub String);
/// An edit asked did not happen, and why.
pub struct Refused(pub EditRefusal);
/// How a document shows (MARK-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -81,11 +87,14 @@ pub struct MarkView {
pub(crate) shown: bool,
/// Trackpad pixels toward the next size step.
wheel: f32,
/// The last right-click menu asked.
offer: Option<ely_office::menu::Offer<menu::Asked>>,
pub focus: FocusHandle,
}
impl EventEmitter<Reported> for MarkView {}
impl EventEmitter<Failed> for MarkView {}
impl EventEmitter<Refused> for MarkView {}
impl MarkView {
pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
@@ -126,6 +135,7 @@ impl MarkView {
folder: None,
shown: false,
wheel: 0.0,
offer: None,
focus,
}
}
@@ -269,6 +279,13 @@ impl MarkView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let DocumentEvent::EditRefused(why) = event {
return cx.emit(Refused(*why));
}
if let DocumentEvent::Pointed(target) = event {
let at = window.mouse_position();
return self.ask_menu(menu::Asked::Text(target.clone()), at, cx);
}
if *event == DocumentEvent::Changed {
let text = document.read(cx).text(cx);
self.doc.lock().expect("the text lock").current = text;
@@ -304,7 +321,7 @@ impl MarkView {
}
impl Render for MarkView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
// Ctrl and the wheel size text, as Ctrl+= does.
let zoom = ely_ui::wheel::zoom_layer(move |event, window, cx| {
@@ -320,10 +337,16 @@ impl Render for MarkView {
.relative()
.size_full()
.track_focus(&self.focus)
.on_mouse_down(
MouseButton::Right,
cx.listener(|view, event, _, cx| view.pressed(event, cx)),
)
.child(self.document.clone())
.child(zoom)
.children(self.context_menu(window, cx))
}
}
mod menu;
#[cfg(test)]
mod tests;
+463
View File
@@ -0,0 +1,463 @@
//! The right-click menu, as Typora's (R4.10).
use std::rc::Rc;
use ely_gpui_component::{
documents::{DocumentTarget, TargetBlock},
menus::{ContextMenu, Menu, MenuItem},
primitives::IconName,
};
use ely_office::menu::Offer;
use ely_ui::i18n::text;
use gpui::{
Action, App, ClipboardItem, Context, Keystroke, MouseDownEvent, Pixels, Point, SharedString,
Window,
};
use super::{Failed, MarkView, Mode};
use crate::{blocks as b, clip, export, tools};
/// What a right press asked a menu for.
pub(crate) enum Asked {
/// The visual mode's target.
Text(DocumentTarget),
Source,
Preview,
}
impl MarkView {
pub(super) fn ask_menu(&mut self, asked: Asked, at: Point<Pixels>, cx: &mut Context<Self>) {
self.offer = Some(Offer::new(self.offer.as_ref(), 0, asked, at));
cx.notify();
}
/// A right press the visual blocks left alone.
pub(super) fn pressed(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
let asked = match self.mode {
Mode::Visual => return,
Mode::Source => Asked::Source,
Mode::Preview => Asked::Preview,
};
log::info!("mark: a right press in {}", self.mode.name());
self.ask_menu(asked, event.position, cx);
}
pub(super) fn context_menu(
&self,
window: &Window,
cx: &mut Context<Self>,
) -> Option<ContextMenu> {
let offer = self.offer.as_ref()?;
let selected = !self.document.read(cx).selected(cx).is_empty();
let menu = match &offer.what {
Asked::Text(target) => self.text_menu(target, selected, window, cx),
Asked::Source => Rows::default()
.buttons(clipboard(selected, cx))
.row(MenuItem::submenu(
said("mark.menu.copy_as", cx),
copy_as(window, cx),
))
.menu(),
Asked::Preview => copies(window, cx).into_iter().fold(Menu::new(), Menu::item),
};
Some(offer.host("mark-menu", menu))
}
/// Typora's groups, shown as the target allows.
fn text_menu(
&self,
target: &DocumentTarget,
selected: bool,
window: &Window,
cx: &mut Context<Self>,
) -> Menu {
use TargetBlock as K;
let view = cx.entity();
let block = target.block;
let is = |kinds: &[K]| block.is_some_and(|block| kinds.contains(&block));
let inline = is(&[
K::Paragraph,
K::Quote,
K::Alert,
K::List,
K::Table,
K::Footnote,
]) || matches!(block, Some(K::Heading(_)));
let mut rows = Rows::default();
if let Some(url) = &target.image {
let url = url.clone();
let key = match web(&url) {
true => "mark.image.browser",
false => "mark.image.location",
};
let (shown, dropped, at) = (view.clone(), view.clone(), target.at);
rows =
rows.row(MenuItem::new(said(key, cx)).on_click(move |_, cx| {
shown.update(cx, |view, cx| view.show_picture(&url, cx))
}))
.row(MenuItem::new(said("mark.image.delete", cx)).on_click(move |_, cx| {
dropped.update(cx, |view, cx| view.delete_image(at, cx))
}))
.line();
}
if let Some(url) = &target.link {
let (url, view) = (url.clone(), view.clone());
rows = rows
.row(
MenuItem::new(said("mark.link.open", cx))
.on_click(move |_, cx| view.update(cx, |view, cx| view.follow(&url, cx))),
)
.line();
}
if is(&[K::Math, K::Html]) {
let (at, view) = (target.at, view.clone());
rows = rows.row(MenuItem::new(said("mark.block.edit", cx)).on_click(
move |window, cx| {
let document = view.read(cx).document.clone();
document.update(cx, |document, cx| document.show_match(at..at, window, cx));
},
));
}
rows = rows.buttons(clipboard(selected, cx));
if let Some(url) = &target.link {
rows = rows.row(copy_text(said("mark.link.copy", cx), url));
}
rows = rows.row(MenuItem::submenu(
said("mark.menu.copy_as", cx),
copy_as(window, cx),
));
if let Some(code) = &target.code {
rows = rows.row(copy_text(said("mark.code.copy", cx), code));
}
rows = rows.line();
if inline {
rows = rows.buttons(formats(window, cx));
}
if !is(&[K::Math, K::Html, K::FrontMatter]) {
rows = rows.buttons(wraps(window, cx));
}
rows = rows.line();
if target.in_table {
rows = rows.row(MenuItem::submenu(
said("mark.menu.table", cx),
table(window, cx),
));
} else if block.is_some() && !is(&[K::Math, K::Html]) {
let name = kind(block, cx);
rows = rows.row(MenuItem::submenu(name, paragraph(window, cx)));
}
rows = rows.row(MenuItem::submenu(
said("mark.menu.insert", cx),
insert(inline, window, cx),
));
if block.is_some() && !is(&[K::Paragraph]) && !matches!(block, Some(K::Heading(_))) {
let name = kind(block, cx);
let label = text(cx, "mark.block.delete", &[("block", &name)]);
rows = rows
.line()
.row(run(label, Box::new(b::DeleteBlock), window));
}
rows.menu()
}
/// Deletes the picture at byte `at`, or says why not.
fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) {
let deleted = (self.document).update(cx, |document, cx| document.delete_image(at, cx));
if let Err(why) = deleted {
cx.emit(super::Refused(why));
}
}
/// Shows a local picture's file, or opens a web one.
fn show_picture(&mut self, url: &str, cx: &mut Context<Self>) {
if web(url) {
log::info!("mark: a picture opens in the browser");
return cx.open_url(url);
}
match crate::output::picture_file(url, self.folder.as_deref()) {
Some(path) if path.is_file() => cx.reveal_path(&path),
_ => {
log::info!("mark: a picture with no file here");
cx.emit(Failed(said("mark.image.no_file", cx).to_string()));
}
}
}
}
fn web(url: &str) -> bool {
let lower = url.to_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
fn said(key: &str, cx: &App) -> SharedString {
text(cx, key, &[])
}
/// Rows with lines only between groups.
#[derive(Default)]
struct Rows {
menu: Menu,
filled: bool,
line: bool,
}
impl Rows {
fn row(self, item: MenuItem) -> Self {
self.with(|menu| menu.item(item))
}
fn buttons(self, items: Vec<MenuItem>) -> Self {
self.with(|menu| menu.buttons(items))
}
fn with(mut self, add: impl FnOnce(Menu) -> Menu) -> Self {
if self.line && self.filled {
self.menu = self.menu.separator();
}
self.menu = add(self.menu);
(self.filled, self.line) = (true, false);
self
}
fn line(mut self) -> Self {
self.line = true;
self
}
fn menu(self) -> Menu {
self.menu
}
}
/// A row that runs `action`, its keys shown.
fn run(label: SharedString, action: Box<dyn Action>, window: &Window) -> MenuItem {
let keys = (window.highest_precedence_binding_for_action(action.as_ref())).map(|binding| {
let strokes: Vec<String> = binding.keystrokes().iter().map(|k| k.unparse()).collect();
strokes.join(" ")
});
let action: Rc<Box<dyn Action>> = Rc::new(action);
let row = MenuItem::new(label)
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx));
match keys {
Some(keys) => row.keys(keys),
None => row,
}
}
/// A button that presses `stroke` where focus went back.
fn press(label: SharedString, icon: IconName, stroke: &str, shown: bool) -> MenuItem {
let keystroke = Keystroke::parse(stroke).expect("a fixed keystroke");
let button = MenuItem::new(label).icon(icon);
let button = match shown {
true => button.keys(keystroke.unparse()),
false => button,
};
button.on_click(move |window, cx| {
let keystroke = keystroke.clone();
window.defer(cx, move |window, cx| {
window.dispatch_keystroke(keystroke, cx);
});
})
}
fn clipboard(selected: bool, cx: &App) -> Vec<MenuItem> {
vec![
press(
said("mark.edit.cut", cx),
IconName::Scissors,
"secondary-x",
true,
)
.disabled(!selected),
press(
said("mark.edit.copy", cx),
IconName::Copy,
"secondary-c",
true,
)
.disabled(!selected),
press(
said("mark.edit.paste", cx),
IconName::Clipboard,
"secondary-v",
true,
),
press(
said("mark.edit.delete", cx),
IconName::Trash2,
"backspace",
false,
)
.disabled(!selected),
]
}
fn copy_text(label: SharedString, copied: &str) -> MenuItem {
let copied = copied.to_string();
MenuItem::new(label).on_click(move |_, cx| {
log::info!("mark: {} bytes copied from the menu", copied.len());
cx.write_to_clipboard(ClipboardItem::new_string(copied.clone()));
})
}
/// Typora's Copy / Paste As rows.
fn copies(window: &Window, cx: &App) -> Vec<MenuItem> {
vec![
run(
said("mark.copy.plain", cx),
Box::new(export::CopyPlain),
window,
),
run(
said("mark.copy.markdown", cx),
Box::new(clip::CopyMarkdown),
window,
),
run(
said("mark.copy.html", cx),
Box::new(export::CopyHtml),
window,
),
run(
said("mark.copy.rich", cx),
Box::new(export::CopyRich),
window,
),
]
}
fn copy_as(window: &Window, cx: &App) -> Menu {
let paste = run(
said("mark.paste.plain", cx),
Box::new(clip::PastePlain),
window,
);
copies(window, cx)
.into_iter()
.fold(Menu::new(), Menu::item)
.separator()
.item(paste)
}
fn formats(window: &Window, cx: &App) -> Vec<MenuItem> {
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
vec![
row("mark.format.strong", Box::new(b::Strong), IconName::Bold),
row(
"mark.format.emphasis",
Box::new(b::Emphasis),
IconName::Italic,
),
row("mark.format.code", Box::new(b::InlineCode), IconName::Code),
row("mark.format.link", Box::new(b::Hyperlink), IconName::Link),
]
}
fn wraps(window: &Window, cx: &App) -> Vec<MenuItem> {
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
vec![
row("mark.block.quote", Box::new(b::Quote), IconName::Quote),
row(
"mark.block.ordered",
Box::new(b::OrderedList),
IconName::ListOrdered,
),
row("mark.block.bullet", Box::new(b::BulletList), IconName::List),
row(
"mark.block.task",
Box::new(b::TaskList),
IconName::ListChecks,
),
]
}
fn paragraph(window: &Window, cx: &App) -> Menu {
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
Menu::new()
.item(row("mark.block.h1", Box::new(b::Heading1)))
.item(row("mark.block.h2", Box::new(b::Heading2)))
.item(row("mark.block.h3", Box::new(b::Heading3)))
.item(row("mark.block.h4", Box::new(b::Heading4)))
.item(row("mark.block.h5", Box::new(b::Heading5)))
.item(row("mark.block.h6", Box::new(b::Heading6)))
.separator()
.item(row("mark.block.paragraph", Box::new(b::Paragraph)))
}
fn table(window: &Window, cx: &App) -> Menu {
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
Menu::new()
.item(row("mark.table.row_above", Box::new(tools::RowAbove)))
.item(row("mark.table.row_below", Box::new(tools::RowBelow)))
.item(row("mark.table.column_left", Box::new(tools::ColumnLeft)))
.item(row("mark.table.column_right", Box::new(tools::ColumnRight)))
.separator()
.item(row("mark.table.move_row_up", Box::new(tools::MoveRowUp)))
.item(row(
"mark.table.move_row_down",
Box::new(tools::MoveRowDown),
))
.item(row(
"mark.table.move_column_left",
Box::new(tools::MoveColumnLeft),
))
.item(row(
"mark.table.move_column_right",
Box::new(tools::MoveColumnRight),
))
.separator()
.item(row("mark.table.delete_row", Box::new(tools::DeleteRow)))
.item(row(
"mark.table.delete_column",
Box::new(tools::DeleteColumn),
))
.separator()
.item(row("mark.table.tidy", Box::new(tools::TidyTable)))
.separator()
.item(row("mark.table.delete", Box::new(tools::DeleteTable)))
}
fn insert(inline: bool, window: &Window, cx: &App) -> Menu {
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
let menu = match inline {
true => Menu::new()
.item(row("mark.insert.image", Box::new(b::InsertImage)))
.separator(),
false => Menu::new(),
};
menu.item(row("mark.block.footnote", Box::new(b::Footnote)))
.item(row("mark.block.link_reference", Box::new(b::LinkReference)))
.item(row("mark.block.rule", Box::new(b::Rule)))
.item(row("mark.menu.table", Box::new(tools::InsertTable)))
.item(row("mark.block.code", Box::new(b::CodeFences)))
.item(row("mark.block.math", Box::new(b::MathBlock)))
.item(row("mark.block.toc", Box::new(b::Toc)))
.item(row("mark.block.front_matter", Box::new(b::FrontMatter)))
.separator()
.item(row("mark.insert.before", Box::new(b::ParagraphBefore)))
.item(row("mark.insert.after", Box::new(b::ParagraphAfter)))
}
/// A block kind's name, as Typora's menus say it.
fn kind(block: Option<TargetBlock>, cx: &App) -> SharedString {
use TargetBlock as K;
let key = match block {
None | Some(K::Paragraph) => "mark.block.paragraph",
Some(K::Heading(level)) => {
return said(&format!("mark.block.h{}", level.clamp(1, 6)), cx);
}
Some(K::Quote) => "mark.block.quote",
Some(K::Alert) => "mark.kind.alert",
Some(K::List) => "mark.kind.list",
Some(K::Code) => "mark.block.code",
Some(K::Math) => "mark.block.math",
Some(K::Table) => "mark.menu.table",
Some(K::Rule) => "mark.block.rule",
Some(K::Html) => "mark.kind.html",
Some(K::Footnote) => "mark.block.footnote",
Some(K::Definition) => "mark.block.link_reference",
Some(K::FrontMatter) => "mark.block.front_matter",
Some(K::Toc) => "mark.block.toc",
};
said(key, cx)
}
+17 -4
View File
@@ -57,6 +57,8 @@ pub struct Deck {
pub remarks_pane: Entity<crate::remarks::Pane>,
/// What the menus last checked: grid, guides, comments.
pub snap_menus: Option<(bool, bool, bool)>,
/// The canvas's right-click menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
/// Pictures of closed tabs and ended previews, freed later.
pub retired: ely_gpui_component::primitives::Retired,
}
@@ -93,7 +95,10 @@ impl Host for Deck {
&& !tab.extra.fitted
{
tab.extra.fitted = true;
view.update(cx, |view, cx| view.fit_page(cx));
view.update(cx, |view, cx| {
view.fit_page(cx);
view.turn_parts();
});
}
// Pictures follow the kernel's light or dark scheme.
let dark = view.read(cx).dark();
@@ -103,6 +108,7 @@ impl Host for Deck {
tab.extra.dark = dark;
self.repaint_all(id, cx);
}
self.offer_menu(id, view, cx);
self.reread(id, view, cx);
}
}
@@ -172,6 +178,7 @@ impl Deck {
reading_fonts: false,
guide_drag: None,
snap_menus: Some((false, false, false)),
menu: None,
notes: Default::default(),
remarks_open: false,
preview: None,
@@ -259,7 +266,12 @@ impl Deck {
Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => {
tab.extra.read_at = Some(at);
log::info!("show: tab {id} has {} parts", slides.list.len());
view.update(cx, |view, _| view.set_part(slides.current));
// A queued turn or part outranks an earlier read.
view.update(cx, |view, _| {
if view.settled() {
view.set_part(slides.current)
}
});
let names: HashSet<&String> =
slides.list.iter().map(|slide| &slide.name).collect();
tab.extra.keep_pictures(&names);
@@ -379,7 +391,7 @@ impl Deck {
self.paint_next(id, cx);
}
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let Some(tab) = self.active_tab() else {
let title = match self.core.loading.first() {
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
@@ -447,7 +459,8 @@ impl Render for Deck {
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(cx));
.children(self.dialog_element(cx))
.children(self.context_menu(cx));
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
Self::bind_zoom(root, cx)
}
+1
View File
@@ -11,6 +11,7 @@ mod guides;
mod insert;
mod list;
mod media;
mod menu;
mod motion;
mod new;
mod notes;
+307
View File
@@ -0,0 +1,307 @@
//! The canvas's right-click menus, laid out as PowerPoint's (SHOW-03).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView,
menu::{Runs, Target, runs},
show::LAYOUTS,
};
use ely_office::menu::{self, Entry, Offer};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, Window};
use crate::deck::Deck;
/// What was right-clicked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// The slide, nothing selected.
Slide,
/// Text being edited.
Text,
/// A shape or a text box.
Object,
Picture,
Table,
}
/// A menu's kind and what the kernel lets run.
pub struct What {
pub kind: Kind,
runs: Runs,
}
/// One thing a menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
/// An index into `LAYOUTS`.
Layout(usize),
Comment,
Bullets,
Numbering,
Group,
Ungroup,
Front,
Forward,
Back,
Backward,
Replace,
Crop,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRows,
DeleteColumns,
DeleteTable,
Merge,
}
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
const LAYOUT_PICKS: [Pick; 12] = [
Pick::Layout(0),
Pick::Layout(1),
Pick::Layout(2),
Pick::Layout(3),
Pick::Layout(4),
Pick::Layout(5),
Pick::Layout(6),
Pick::Layout(7),
Pick::Layout(8),
Pick::Layout(9),
Pick::Layout(10),
Pick::Layout(11),
];
const SLIDE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("show.design.layout", &LAYOUT_PICKS),
Entry::Line,
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::Bullets),
Entry::Row(Pick::Numbering),
Entry::Line,
Entry::Row(Pick::Comment),
];
const OBJECT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Comment),
];
const PICTURE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::Replace),
Entry::Row(Pick::Crop),
Entry::Line,
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Comment),
];
const TABLE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.insert",
&[Pick::RowAbove, Pick::RowBelow, Pick::ColumnLeft, Pick::ColumnRight],
),
Entry::Sub("doc.menu.delete", &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable]),
Entry::Row(Pick::Merge),
Entry::Line,
Entry::Row(Pick::Comment),
];
/// Each layout's label, in `LAYOUTS` order.
const LAYOUT_KEYS: [&str; 12] = [
"show.layout.20",
"show.layout.0",
"show.layout.1",
"show.layout.3",
"show.layout.19",
"show.layout.32",
"show.layout.12",
"show.layout.15",
"show.layout.14",
"show.layout.16",
"show.layout.18",
"show.layout.34",
];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::Layout(index) => (LAYOUT_KEYS[index], ".uno:AssignLayout"),
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
Pick::Bullets => ("doc.props.bullets", ".uno:DefaultBullet"),
Pick::Numbering => ("doc.props.numbering", ".uno:DefaultNumbering"),
Pick::Group => ("show.object.group", ".uno:FormatGroup"),
Pick::Ungroup => ("show.object.ungroup", ".uno:FormatUngroup"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
Pick::Replace => ("show.object.replace", ".uno:ChangePicture"),
Pick::Crop => ("show.object.crop", ".uno:Crop"),
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
}
}
}
impl What {
/// The kernel lets the pick's own command run now.
pub(crate) fn on(&self, pick: Pick) -> bool {
self.runs.allows(menu::Pick::row(pick).1)
}
}
impl Kind {
fn of(target: Target) -> Option<Kind> {
match target {
Target::Slide => Some(Kind::Slide),
Target::Text => Some(Kind::Text),
Target::Object => Some(Kind::Object),
Target::Picture => Some(Kind::Picture),
Target::Table => Some(Kind::Table),
Target::Frame | Target::Cell => None,
}
}
/// Its rows under the clipboard row.
fn rows(self) -> &'static [Entry<Pick>] {
match self {
Kind::Slide => SLIDE,
Kind::Text => TEXT,
Kind::Object => OBJECT,
Kind::Picture => PICTURE,
Kind::Table => TABLE,
}
}
/// Every command its rows run.
fn commands(self) -> Vec<&'static str> {
let rows = self.rows().iter().flat_map(|entry| match entry {
Entry::Row(pick) => std::slice::from_ref(pick),
Entry::Sub(_, picks) => picks,
Entry::Line => &[],
});
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
commands.sort_unstable();
commands.dedup();
commands
}
}
impl Deck {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let Some(kind) = Kind::of(offered.target) else {
let error = format!("a {:?} menu in a presentation", offered.target);
return self.toast(Deck::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
};
let commands = kind.commands();
self.ask_in(
id,
move |worker, doc| runs(worker, doc, &commands),
move |deck, read, _, cx| match read {
Ok(runs) => {
deck.menu = Some(Offer::new(deck.menu.as_ref(), id, What { kind, runs }, at));
cx.notify();
}
Err(error) => deck.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// The menu's host, for the active deck.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("show-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("show: the menu picks {pick:?}");
let id = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Comment => return self.ask_remark(None, window, cx),
Pick::Replace => return self.pick_picture(true, cx),
Pick::Layout(index) => {
return self.design_command("slide.layout", &[&LAYOUTS[index].to_string()], cx);
}
Pick::Bullets => "list.bullets",
Pick::Numbering => "list.numbering",
Pick::Group => "object.group",
Pick::Ungroup => "object.ungroup",
Pick::Front => "image.front",
Pick::Forward => "image.forward",
Pick::Back => "image.back",
Pick::Backward => "image.backward",
Pick::Crop => "image.crop",
Pick::RowAbove => "table.row_above",
Pick::RowBelow => "table.row_below",
Pick::ColumnLeft => "table.column_left",
Pick::ColumnRight => "table.column_right",
Pick::DeleteRows => "table.delete_rows",
Pick::DeleteColumns => "table.delete_columns",
Pick::DeleteTable => "table.delete",
Pick::Merge => "table.merge",
};
self.object_command(id, &[], cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("show: {pick:?} with no deck");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// The clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Deck>, what: &What, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Deck::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!what.on(pick))
.on_click(move |window, cx| this.update(cx, |deck, cx| deck.choose(pick, window, cx)))
};
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
}
+3 -1
View File
@@ -234,6 +234,7 @@ impl Render for Strip {
let label = format!("{} {}", part + 1, slide.name);
div()
.id(("show-slide", part))
.debug_selector(move || format!("show-slide-{part}"))
.tab_index(0)
.focus_ring(cx)
.flex()
@@ -301,6 +302,7 @@ impl Render for Strip {
.children(slides);
div()
.size_full()
.child(ContextMenu::new("show-strip-menu", popup(cx)).child(list))
// A sized host lets the list overflow and scroll.
.child(ContextMenu::new("show-strip-menu", popup(cx)).size_full().child(list))
}
}
+3
View File
@@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod menu;
mod motion;
mod notes;
mod objects;
@@ -17,6 +18,8 @@ mod play_more;
mod play_time;
mod review;
mod slides;
mod strip;
mod turn;
/// An unrendered Deck over a fresh worker, if LOK exists.
pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
+138
View File
@@ -0,0 +1,138 @@
use ely_gpui_component::theme::Theme;
use ely_lok::show::Objects;
use ely_office::prelude::*;
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
use super::objects::{act, blank, objects};
use super::settle;
use crate::deck::Deck;
use crate::menu::{Kind, Pick};
/// The canvas with the menu's host over it.
struct Host(Entity<Deck>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let body = self.0.update(cx, |deck, cx| deck.body(window, cx));
let menu = self.0.update(cx, |deck, cx| deck.context_menu(cx));
gpui::div().relative().size_full().flex().flex_col().child(body).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// A slide point, in 1/100 mm, in window pixels.
fn spot(cx: &mut VisualTestContext, deck: &Entity<Deck>, (x, y): (i64, i64)) -> Point<Pixels> {
frames(cx);
cx.update(|_, app| {
let read = deck.read(app).active_tab().expect("a tab").view.read(app);
let twips = |mm100| crate::object::twips(mm100) as i32;
read.origin() + read.place((twips(x), twips(y)))
})
}
/// A point `1/part` into the first object of `kind`.
fn within(cx: &mut VisualTestContext, deck: &Entity<Deck>, kind: &str, part: i64) -> Point<Pixels> {
let (x, y, w, h) = cx.update(|_, app| {
let objects = objects(app, deck).expect("objects");
objects.objects.iter().find(|object| object.kind == kind).expect("the object").rect
});
spot(cx, deck, (x + w / part, y + h / part))
}
fn click(cx: &mut VisualTestContext, at: Point<Pixels>, button: MouseButton) {
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, button, Modifiers::none());
cx.simulate_mouse_up(at, button, Modifiers::none());
}
/// Right-clicks and waits for a menu of `kind`.
fn right_click(cx: &mut VisualTestContext, deck: &Entity<Deck>, at: Point<Pixels>, kind: Kind) {
cx.update(|_, app| deck.update(app, |deck, _| deck.menu = None));
click(cx, at, MouseButton::Right);
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| deck.read(app).menu.as_ref().is_some_and(|offer| offer.what.kind == kind)) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let got = cx.update(|_, app| deck.read(app).menu.as_ref().map(|offer| offer.what.kind));
panic!("no {kind:?} menu, got {got:?}");
}
fn on(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) -> bool {
cx.update(|_, app| deck.read(app).menu.as_ref().expect("a menu").what.on(pick))
}
fn choose(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) {
cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx)));
}
fn table_height(objects: &Objects) -> Option<i64> {
objects.objects.iter().find(|object| object.kind == "table").map(|object| object.rect.3)
}
#[test]
fn right_clicks_open_powerpoint_rows_on_each_target() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| Theme::update(cx, |theme| theme.reduced_motion = true));
settle(&cx, |app| objects(app, &deck).is_some_and(|o| !o.objects.is_empty()));
let window = cx.add_window(|_, _| Host(deck.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let corner = spot(&mut shown, &deck, (27_000, 15_000));
right_click(&mut shown, &deck, corner, Kind::Slide);
let clipboard = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
assert!((clipboard.origin - corner).x < gpui::px(40.0), "at the click");
assert!(on(&mut shown, &deck, Pick::Layout(0)) && on(&mut shown, &deck, Pick::Comment));
// Typing in the title: list rows.
let title = within(&mut shown, &deck, "text", 2);
click(&mut shown, title, MouseButton::Left);
right_click(&mut shown, &deck, title, Kind::Text);
assert!(on(&mut shown, &deck, Pick::Bullets) && on(&mut shown, &deck, Pick::Numbering));
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
act(&mut cx, &deck, rectangle, |o| o.selection.as_ref().is_some_and(|s| s.kind == "shape"));
let shape = within(&mut shown, &deck, "shape", 2);
right_click(&mut shown, &deck, shape, Kind::Object);
assert!(on(&mut shown, &deck, Pick::Back) && on(&mut shown, &deck, Pick::Forward));
assert!(!on(&mut shown, &deck, Pick::Group), "one shape does not group");
choose(&mut shown, &deck, Pick::Back);
settle(&cx, |app| objects(app, &deck).is_some_and(|o| o.objects.first().is_some_and(|first| first.kind == "shape")));
let path = dir.path().join("red.png");
image::RgbaImage::from_pixel(300, 200, image::Rgba([255, 0, 0, 255])).save(&path).expect("the png writes");
let image = ("image.insert", &[ely_lok::file_url(&path)][..]);
let image: (&str, Vec<&str>) = (image.0, image.1.iter().map(String::as_str).collect());
act(&mut cx, &deck, (image.0, &image.1), |o| o.selection.as_ref().is_some_and(|s| s.kind == "image"));
let picture = within(&mut shown, &deck, "image", 2);
right_click(&mut shown, &deck, picture, Kind::Picture);
assert!(on(&mut shown, &deck, Pick::Replace) && on(&mut shown, &deck, Pick::Crop));
act(&mut cx, &deck, ("table.insert", &["2", "2"]), |o| table_height(o).is_some());
// A quarter in: the middle is where the borders cross.
let cell = within(&mut shown, &deck, "table", 4);
right_click(&mut shown, &deck, cell, Kind::Table);
assert!(on(&mut shown, &deck, Pick::RowAbove), "inserting needs no cell");
// Deleting rows needs the cursor in a cell.
click(&mut shown, cell, MouseButton::Left);
right_click(&mut shown, &deck, cell, Kind::Table);
assert!(on(&mut shown, &deck, Pick::DeleteRows) && on(&mut shown, &deck, Pick::Merge));
let before = cx.update(|app| objects(app, &deck).and_then(|o| table_height(&o))).expect("a table");
choose(&mut shown, &deck, Pick::RowBelow);
settle(&cx, |app| objects(app, &deck).and_then(|o| table_height(&o)).is_some_and(|h| h > before));
// A right click that ends an edit gets no menu.
click(&mut shown, corner, MouseButton::Left);
right_click(&mut shown, &deck, corner, Kind::Slide);
choose(&mut shown, &deck, Pick::Layout(0));
let layout = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.design.as_ref().map(|d| d.layout));
settle(&cx, |app| layout(app) == Some(20));
}
+47
View File
@@ -0,0 +1,47 @@
use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
use super::objects::blank;
use super::{counts, settle, update};
use crate::slides::Strip;
/// The slide strip alone in a window.
struct Host(Entity<Strip>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
gpui::div().size_full().child(self.0.clone())
}
}
#[test]
fn the_wheel_scrolls_the_strip_to_its_last_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
return eprintln!("SKIP: no LOK build");
};
for n in 2..=24 {
update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx));
settle(&cx, |app| counts(app, &deck).0 == n);
}
let strip = cx.update(|app| deck.read(app).strip.clone());
let window = cx.add_window(|_, _| Host(strip));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
shown.run_until_parked();
let height = shown.update(|window, _| window.viewport_size().height);
let last = |shown: &mut VisualTestContext| shown.debug_bounds("show-slide-23").expect("the last slide is laid out");
let before = last(&mut shown);
assert!(before.bottom() > height, "the strip is longer than the window");
let at = point(before.left() + px(20.0), height / 2.0);
for _ in 0..20 {
shown.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.0), px(-1000.0))),
..Default::default()
});
shown.run_until_parked();
}
let after = last(&mut shown);
assert!(after.bottom() <= height, "the last slide is in view: {after:?}");
}
+76
View File
@@ -0,0 +1,76 @@
use ely_lok::LokView;
use ely_office::prelude::*;
use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point};
use super::objects::{blank, selection};
use super::{counts, quiet, settle, update};
use crate::deck::Deck;
/// The slide canvas alone in a window.
struct Host(Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
gpui::div().size_full().child(self.0.clone())
}
}
/// The current slide once every turn landed.
fn current(shown: &mut VisualTestContext, deck: &Entity<Deck>) -> usize {
quiet(shown, deck);
shown.update(|_, app| deck.read(app).active_tab().map(|tab| tab.extra.slides.current).expect("a tab"))
}
/// Whole notches, in this platform's lines.
fn wheel(shown: &mut VisualTestContext, notches: f32) {
let middle = shown.update(|window, _| window.viewport_size()).center();
let delta = ScrollDelta::Lines(point(0.0, notches * ely_lok::notch()));
shown.simulate_event(ScrollWheelEvent { position: middle.into(), delta, ..Default::default() });
}
#[test]
fn the_wheel_and_up_down_turn_slides() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
return eprintln!("SKIP: no LOK build");
};
for n in 2..=3 {
update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx));
settle(&cx, |app| counts(app, &deck).0 == n);
}
let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone());
cx.update(|app| view.update(app, |view, _| view.show_part(0)));
let window = cx.add_window(|_, _| Host(view.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
shown.run_until_parked();
assert_eq!(current(&mut shown, &deck), 0);
wheel(&mut shown, -2.0);
assert_eq!(current(&mut shown, &deck), 2, "two notches in one event turn two");
wheel(&mut shown, 1.0);
wheel(&mut shown, 1.0);
assert_eq!(current(&mut shown, &deck), 0, "rapid notches each turn");
wheel(&mut shown, -1.0);
assert_eq!(current(&mut shown, &deck), 1, "the wheel turns down");
shown.update(|window, app| {
let focus = view.read(app).focus_handle().clone();
window.focus(&focus, app)
});
shown.simulate_keystrokes("down");
assert_eq!(current(&mut shown, &deck), 2, "Down turns");
shown.simulate_keystrokes("down");
assert_eq!(current(&mut shown, &deck), 2, "the last slide stays");
shown.simulate_keystrokes("up");
assert_eq!(current(&mut shown, &deck), 1, "Up turns back");
wheel(&mut shown, 1.0);
assert_eq!(current(&mut shown, &deck), 0, "the wheel turns up");
// Down queued behind Tab moves the object Tab picks.
shown.simulate_keystrokes("tab down");
assert_eq!(current(&mut shown, &deck), 0, "Down after Tab moves, not turns");
assert!(shown.update(|_, app| selection(app, &deck)).is_some(), "Tab picked an object");
// A turn lands before the keys queued after it.
shown.simulate_keystrokes("escape down tab down");
assert_eq!(current(&mut shown, &deck), 1, "Down turns, then moves");
assert!(shown.update(|_, app| selection(app, &deck)).is_some(), "Tab picked on the new slide");
}
+1
View File
@@ -23,6 +23,7 @@ ely-ui.workspace = true
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
gpui = { workspace = true, features = ["test-support"] }
+6 -1
View File
@@ -61,6 +61,8 @@ pub struct Book {
pub drag: Option<Drag>,
/// The sheet menu's request: number and place.
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
/// The grid or header menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
pub panel: Entity<Panel>,
pub panel_shown: bool,
pub choices: Option<crate::rules::Choices>,
@@ -95,6 +97,7 @@ impl Host for Book {
self.freeze(id, view, cx);
self.found(id, view, cx);
self.requested(id, view, cx);
self.offer_menu(id, view, cx);
self.follow_rule(id, view, cx);
self.read_links(id, view, cx);
if self.core.active == Some(id) {
@@ -190,6 +193,7 @@ impl Book {
bars: Bars::new(window, cx),
drag: None,
sheet_menu: None,
menu: None,
panel,
panel_shown: true,
choices: None,
@@ -388,7 +392,8 @@ impl Render for Book {
.on_action(cx.listener(Self::on_print))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(cx));
.children(self.dialog_element(cx))
.children(self.context_menu(cx));
let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx);
let root = crate::data::bind(crate::formulas::bind(root, cx), cx);
let root = crate::page::bind(crate::protect::bind(root, cx), cx);
+1 -1
View File
@@ -144,7 +144,7 @@ impl Book {
self.run_in(Some(tab), id, &[&size.to_string()], cx);
}
fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
+14 -6
View File
@@ -140,6 +140,12 @@ impl Book {
}
}
/// Selects a header's row or column.
pub(crate) fn select_header(&mut self, column: bool, index: u32, modifier: &str, cx: &mut Context<Self>) {
let id = if column { "select.column" } else { "select.row" };
self.run_active(id, &[&index.to_string(), modifier], cx);
}
/// One row or column header with its sizing edge.
fn header(
&self,
@@ -201,13 +207,15 @@ impl Book {
.border_color(transparent_black())
.focus_ring(cx)
.tab_index(0)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |book, event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
book.header_menu(column, index, event.position, cx);
}),
)
.on_click(cx.listener(move |book, event: &ClickEvent, _, cx| {
let id = if column {
"select.column"
} else {
"select.row"
};
book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx);
book.select_header(column, index, &modifier(&event.modifiers()), cx);
}))
.child(label);
let cell = div()
+1
View File
@@ -11,6 +11,7 @@ mod figures;
mod format;
mod formulas;
mod grid;
mod menu;
mod output;
mod page;
mod protect;
+326
View File
@@ -0,0 +1,326 @@
//! The right-click menus, laid out as Excel's (TABLE-02).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView, Rect,
menu::{Runs, Target, runs},
sheet::Geometry,
};
use ely_office::menu::{self, Entry, Offer};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, Pixels, Point, Window};
use crate::book::Book;
/// What was right-clicked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Cell,
Object,
/// A cell's text, being edited.
Text,
Rows,
Columns,
}
/// A menu's kind and what the kernel lets run.
pub struct What {
pub kind: Kind,
runs: Runs,
}
/// One thing a menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
RowsAbove,
RowsBelow,
ColumnsBefore,
ColumnsAfter,
InsertDown,
InsertRight,
DeleteRows,
DeleteColumns,
DeleteUp,
DeleteLeft,
Clear,
Merge,
Split,
Format,
RowHeight,
ColumnWidth,
HideRows,
ShowRows,
HideColumns,
ShowColumns,
Front,
Forward,
Back,
Backward,
}
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
const CELL: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"table.menu.insert",
&[
Pick::RowsAbove,
Pick::RowsBelow,
Pick::ColumnsBefore,
Pick::ColumnsAfter,
Pick::InsertDown,
Pick::InsertRight,
],
),
Entry::Sub(
"table.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteUp, Pick::DeleteLeft],
),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::Merge),
Entry::Row(Pick::Split),
Entry::Line,
Entry::Row(Pick::Format),
];
const OBJECT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
];
const ROWS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::RowsAbove),
Entry::Row(Pick::DeleteRows),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::RowHeight),
Entry::Row(Pick::HideRows),
Entry::Row(Pick::ShowRows),
];
const COLUMNS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::ColumnsBefore),
Entry::Row(Pick::DeleteColumns),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::ColumnWidth),
Entry::Row(Pick::HideColumns),
Entry::Row(Pick::ShowColumns),
];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::RowsAbove => ("table.cells.rows_above", ".uno:InsertRowsBefore"),
Pick::RowsBelow => ("table.cells.rows_below", ".uno:InsertRowsAfter"),
Pick::ColumnsBefore => ("table.cells.columns_before", ".uno:InsertColumnsBefore"),
Pick::ColumnsAfter => ("table.cells.columns_after", ".uno:InsertColumnsAfter"),
Pick::InsertDown => ("table.cells.insert_down", ".uno:InsertCell"),
Pick::InsertRight => ("table.cells.insert_right", ".uno:InsertCell"),
Pick::DeleteRows => ("table.cells.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("table.cells.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteUp => ("table.cells.delete_up", ".uno:DeleteCell"),
Pick::DeleteLeft => ("table.cells.delete_left", ".uno:DeleteCell"),
Pick::Clear => ("table.menu.clear", ".uno:ClearContents"),
Pick::Merge => ("table.cells.merge", ".uno:MergeCells"),
Pick::Split => ("table.cells.split", ".uno:SplitCell"),
Pick::Format => ("table.menu.format", ".uno:FormatCellDialog"),
Pick::RowHeight => ("table.cells.row_height", ".uno:RowHeight"),
Pick::ColumnWidth => ("table.cells.column_width", ".uno:ColumnWidth"),
Pick::HideRows => ("table.cells.hide_rows", ".uno:HideRow"),
Pick::ShowRows => ("table.cells.show_rows", ".uno:ShowRow"),
Pick::HideColumns => ("table.cells.hide_columns", ".uno:HideColumn"),
Pick::ShowColumns => ("table.cells.show_columns", ".uno:ShowColumn"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
}
}
}
impl What {
/// The kernel lets the pick's own command run now.
pub(crate) fn on(&self, pick: Pick) -> bool {
self.runs.allows(menu::Pick::row(pick).1)
}
}
impl Kind {
/// Its rows under the clipboard row.
fn rows(self) -> &'static [Entry<Pick>] {
match self {
Kind::Cell => CELL,
Kind::Object => OBJECT,
Kind::Text => &[],
Kind::Rows => ROWS,
Kind::Columns => COLUMNS,
}
}
/// Every command its rows run.
fn commands(self) -> Vec<&'static str> {
let rows = self.rows().iter().flat_map(|entry| match entry {
Entry::Row(pick) => std::slice::from_ref(pick),
Entry::Sub(_, picks) => picks,
Entry::Line => &[],
});
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
commands.sort_unstable();
commands.dedup();
commands
}
}
/// A whole selected row or column covers the header.
pub(crate) fn held(selection: &[Rect], geometry: &Geometry, column: bool, index: u32) -> bool {
let (along, across) = if column {
(&geometry.columns, &geometry.rows)
} else {
(&geometry.rows, &geometry.columns)
};
let (from, to) = (along.start(index), along.start(index + 1));
let end = across.start(across.count());
selection.iter().any(|&(x, y, w, h)| {
let ((start, size), (side, span)) = if column { ((x, w), (y, h)) } else { ((y, h), (x, w)) };
side == 0 && span + 1 >= end && start <= from && to <= start + size + 1
})
}
impl Book {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let kind = match offered.target {
Target::Cell => Kind::Cell,
Target::Object | Target::Picture => Kind::Object,
Target::Text => Kind::Text,
Target::Table | Target::Frame | Target::Slide => {
let error = format!("a {:?} menu in a spreadsheet", offered.target);
return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
}
};
self.offer(id, kind, at, cx);
}
/// Selects a header, then offers its menu.
pub(crate) fn header_menu(&mut self, column: bool, index: u32, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
// A selected object keeps nothing held.
let read = tab.view.read(cx);
if read.graphic().is_some() || !held(read.selection(), &tab.extra.geometry, column, index) {
self.select_header(column, index, "0", cx);
}
log::info!("table: a header menu, column {column}");
self.menu = None;
let kind = if column { Kind::Columns } else { Kind::Rows };
match self.settled(id, cx) {
true => self.offer(id, kind, at, cx),
false => self.when_settled(id, move |book, _, cx| book.offer(id, kind, at, cx)),
}
cx.notify();
}
/// Asks what may run now, then opens the menu.
fn offer(&mut self, id: u64, kind: Kind, at: Point<Pixels>, cx: &mut Context<Self>) {
let commands = kind.commands();
self.ask_in(
id,
move |worker, doc| runs(worker, doc, &commands),
move |book, read, _, cx| match read {
Ok(runs) => {
book.menu = Some(Offer::new(book.menu.as_ref(), id, What { kind, runs }, at));
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// The menu's host, for the active workbook.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("table-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("table: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Merge => return self.merge(self.core.active, cx),
Pick::RowHeight => return self.ask_size(false, window, cx),
Pick::ColumnWidth => return self.ask_size(true, window, cx),
Pick::Format => {
self.panel_shown = true;
return cx.notify();
}
Pick::RowsAbove => ("rows.insert_above", &[]),
Pick::RowsBelow => ("rows.insert_below", &[]),
Pick::ColumnsBefore => ("columns.insert_before", &[]),
Pick::ColumnsAfter => ("columns.insert_after", &[]),
Pick::InsertDown => ("cells.insert", &["V"]),
Pick::InsertRight => ("cells.insert", &[">"]),
Pick::DeleteRows => ("rows.delete", &[]),
Pick::DeleteColumns => ("columns.delete", &[]),
Pick::DeleteUp => ("cells.delete", &["U"]),
Pick::DeleteLeft => ("cells.delete", &["L"]),
Pick::Clear => ("cells.clear", &[]),
Pick::Split => ("cells.split", &[]),
Pick::HideRows => ("rows.hide", &[]),
Pick::ShowRows => ("rows.show", &[]),
Pick::HideColumns => ("columns.hide", &[]),
Pick::ShowColumns => ("columns.show", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("table: {pick:?} with no workbook");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// The clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Book>, what: &What, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Book::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!what.on(pick))
.on_click(move |window, cx| this.update(cx, |book, cx| book.choose(pick, window, cx)))
};
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
}
+1
View File
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
mod analysis;
mod data;
mod menu;
mod names;
mod numbers;
mod output;
+260
View File
@@ -0,0 +1,260 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, sheet};
use gpui::{Modifiers, MouseButton, Pixels, Point, VisualTestContext};
use super::data::{cells, open, run};
use super::*;
use crate::menu::{Kind, Pick, What, held};
type Shows = fn(&What) -> bool;
/// The grid with the menu's host over it.
struct Host(Entity<Book>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
let menu = self.0.update(cx, |book, cx| book.context_menu(cx));
gpui::div().relative().size_full().flex().flex_col().child(grid).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
fn view(cx: &mut VisualTestContext, book: &Entity<Book>) -> Entity<LokView> {
cx.update(|_, app| book.read(app).active_tab().expect("a tab").view.clone())
}
/// A cell's middle or its header's, in window pixels.
fn spot(cx: &mut VisualTestContext, book: &Entity<Book>, column: u32, row: u32, header: Option<bool>) -> Point<Pixels> {
frames(cx);
let view = view(cx, book);
cx.update(|_, app| {
let geometry = &book.read(app).active_tab().expect("a tab").extra.geometry;
let x = geometry.columns.start(column) + geometry.columns.size(column) / 2;
let y = geometry.rows.start(row) + geometry.rows.size(row) / 2;
let read = view.read(app);
let at = read.origin() + read.place((x, y));
match header {
None => at,
Some(false) => gpui::point(read.origin().x - gpui::px(24.0), at.y),
Some(true) => gpui::point(at.x, read.origin().y - gpui::px(12.0)),
}
})
}
/// Right-clicks and waits until `shown` holds for the menu.
fn right_click(cx: &mut VisualTestContext, book: &Entity<Book>, at: Point<Pixels>, shown: Shows) {
cx.update(|_, app| book.update(app, |book, _| book.menu = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| book.read(app).menu.as_ref().is_some_and(|offer| shown(&offer.what))) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no menu");
}
fn on(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) -> bool {
cx.update(|_, app| book.read(app).menu.as_ref().expect("a menu").what.on(pick))
}
fn choose(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) {
cx.update(|window, app| book.update(app, |book, cx| book.choose(pick, window, cx)));
}
/// A column range's cells, empty ones kept.
fn column(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> Vec<String> {
cells(cx, book, range).lines().map(|line| line.trim().to_string()).collect()
}
fn cell(what: &What) -> bool {
what.kind == Kind::Cell
}
fn rows(what: &What) -> bool {
what.kind == Kind::Rows
}
fn columns(what: &What) -> bool {
what.kind == Kind::Columns
}
/// A new, selected rectangle over A1.
fn corner_rectangle(cx: &mut TestAppContext, book: &Entity<Book>) {
run(cx, book, "draw.shape", &["rectangle", "true"]);
run(cx, book, "object.x", &["0"]);
run(cx, book, "object.y", &["0"]);
let selected = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(selected.is_some_and(|(x, y, ..)| x < 100 && y < 100), "selected at A1: {selected:?}");
}
/// Protects the sheet, allowing selection and `more`.
fn protect(cx: &mut TestAppContext, book: &Entity<Book>, more: &[&str]) {
let allow: Vec<String> = ["selectLocked", "selectUnlocked"].iter().chain(more).map(|key| key.to_string()).collect();
run(cx, book, "sheet.protect", &[&sheet::protect_json("pw", &allow)]);
}
#[test]
fn right_clicks_open_excel_rows_on_cells_and_headers() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.geometry.rows.count() > 0));
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - a2;
assert!(off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0), "at the click: {off:?}");
assert!(on(&mut shown, &book, Pick::RowsBelow) && on(&mut shown, &book, Pick::Clear));
assert!(!on(&mut shown, &book, Pick::Merge), "one cell does not merge");
choose(&mut shown, &book, Pick::RowsBelow);
assert_eq!(column(&mut cx, &book, "A2:A4"), ["a", "", "b"], "a row below A2");
// The read left A2:A4 selected; a click outside it moves.
run(&mut cx, &book, "cell.goto", &["D9"]);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
choose(&mut shown, &book, Pick::Clear);
assert_eq!(column(&mut cx, &book, "A1:A4"), ["Name", "", "", "b"], "cleared");
assert_eq!(cells(&mut cx, &book, "B2"), "3", "one cell only");
let row5 = spot(&mut shown, &book, 0, 4, Some(false));
right_click(&mut shown, &book, row5, rows);
assert!(on(&mut shown, &book, Pick::HideRows) && on(&mut shown, &book, Pick::DeleteRows));
choose(&mut shown, &book, Pick::DeleteRows);
assert_eq!(cells(&mut cx, &book, "A5"), "c", "row 5 went, not row 2");
// A selected rectangle over A1 hides no header menu.
corner_rectangle(&mut cx, &book);
let row1 = spot(&mut shown, &book, 0, 0, Some(false));
right_click(&mut shown, &book, row1, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove) && on(&mut shown, &book, Pick::HideRows));
choose(&mut shown, &book, Pick::RowsAbove);
assert_eq!(cells(&mut cx, &book, "A2"), "Name", "a row above row 1");
corner_rectangle(&mut cx, &book);
let a = spot(&mut shown, &book, 0, 0, Some(true));
right_click(&mut shown, &book, a, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore) && on(&mut shown, &book, Pick::DeleteColumns));
protect(&mut cx, &book, &[]);
let headers: [(Point<Pixels>, Shows, [Pick; 4]); 2] = [
(spot(&mut shown, &book, 0, 2, Some(false)), rows, [Pick::RowsAbove, Pick::DeleteRows, Pick::HideRows, Pick::ShowRows]),
(spot(&mut shown, &book, 1, 0, Some(true)), columns, [Pick::ColumnsBefore, Pick::DeleteColumns, Pick::HideColumns, Pick::ShowColumns]),
];
for (at, shown_as, picks) in headers {
right_click(&mut shown, &book, at, shown_as);
assert!(on(&mut shown, &book, Pick::Copy), "copy stays on");
for pick in picks.into_iter().chain([Pick::Cut, Pick::Clear]) {
assert!(!on(&mut shown, &book, pick), "{pick:?} is off on a protected sheet");
}
}
// Protection that lets rows and columns in.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
protect(&mut cx, &book, &["insertRows", "insertColumns"]);
let b = spot(&mut shown, &book, 1, 0, Some(true));
right_click(&mut shown, &book, b, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore), "inserting columns is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteColumns), "deleting columns is not");
let row2 = spot(&mut shown, &book, 0, 1, Some(false));
right_click(&mut shown, &book, row2, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove), "inserting rows is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteRows), "deleting rows is not");
choose(&mut shown, &book, Pick::RowsAbove);
// Protected cells read as nothing.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
assert_eq!(cells(&mut cx, &book, "A3"), "Name", "a row above row 2");
}
#[test]
fn a_header_keeps_a_whole_selection_that_holds_it() {
let geometry = sheet::geometry(
r#"{"rows":{"sizes":"256:1048575","hidden":"0:1048575"},"columns":{"sizes":"1280:16383","hidden":"0:16383"}}"#,
)
.expect("parses");
// As Calc reports column B, then row 3.
let column = [(1280, 0, 1279, 268435455)];
assert!(held(&column, &geometry, true, 1));
assert!(!held(&column, &geometry, true, 0));
assert!(!held(&column, &geometry, false, 0), "a column is no row");
let row = [(0, 512, 20971519, 255)];
assert!(held(&row, &geometry, false, 2));
assert!(!held(&row, &geometry, true, 0));
let one = [(1280, 256, 1279, 255)];
assert!(!held(&one, &geometry, true, 1), "one cell is no column");
assert!(!held(&[], &geometry, false, 0));
}
/// Starts typing into the selected shape's text.
fn type_in_shape(cx: &mut TestAppContext, book: &Entity<Book>) {
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker");
worker.post_key(&tab.doc, 0, 769).expect("F2");
worker.post_key(&tab.doc, 'z' as u32, 537).expect("a key");
});
fresh(cx, book);
let graphic = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(graphic.is_none(), "editing the shape's text: {graphic:?}");
}
#[test]
fn a_header_ends_shape_text_editing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
fresh(&cx, &book);
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let headers: [(bool, u32, Shows, Pick); 2] =
[(false, 2, rows, Pick::RowsAbove), (true, 1, columns, Pick::ColumnsBefore)];
for (column, index, shown_as, insert) in headers {
corner_rectangle(&mut cx, &book);
type_in_shape(&mut cx, &book);
let at = if column { spot(&mut shown, &book, index, 2, Some(true)) } else { spot(&mut shown, &book, 0, index, Some(false)) };
right_click(&mut shown, &book, at, shown_as);
let picked = cx.update(|app| {
let tab = book.read(app).active_tab().expect("a tab");
held(tab.view.read(app).selection(), &tab.extra.geometry, column, index)
});
assert!(picked, "the header's line is selected, column {column}");
assert!(on(&mut shown, &book, insert), "{insert:?} is on");
choose(&mut shown, &book, insert);
}
assert_eq!(cells(&mut cx, &book, "A4"), "b", "a row above row 3");
assert_eq!(cells(&mut cx, &book, "C2"), "3", "a column before B");
let path = dir.path().join("shapes.fods");
cx.update(|app| {
let book = book.read(app);
let mut worker = book.core.worker.lock().expect("the worker");
let doc = &book.active_tab().expect("a tab").doc;
doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods").expect("the save");
});
let xml = std::fs::read_to_string(&path).expect("the save");
assert_eq!(xml.matches(">z</text:p>").count(), 2, "each shape keeps its typed text");
}
+11 -1
View File
@@ -39,6 +39,14 @@ pub fn forget(file: &Path, root: &Path) -> io::Result<()> {
.map(drop)
}
/// Empties the list.
pub fn clear(file: &Path) -> io::Result<()> {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
crate::save_atomic(file, b"[]", 0).map(drop)
}
/// Puts `root` first in the list and writes it.
pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
let mut list = recent(file)?;
@@ -60,7 +68,7 @@ pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
mod tests {
use std::path::{Path, PathBuf};
use super::{forget, recent, remember};
use super::{clear, forget, recent, remember};
#[test]
fn the_list_puts_the_newest_first_and_keeps_twenty() {
@@ -82,6 +90,8 @@ mod tests {
let plain = [PathBuf::from(r"C:\r\old"), PathBuf::from(r"C:\r\new")];
assert_eq!(recent(&file).expect("reads"), plain);
}
clear(&file).expect("clears");
assert!(recent(&file).expect("reads").is_empty());
std::fs::write(&file, "not json").expect("writes");
assert!(
remember(&file, Path::new("/r/1")).is_err(),
+7 -2
View File
@@ -53,6 +53,11 @@ pub fn check_reply(asked: u64, got: u64) -> io::Result<()> {
/// Reads (tag, id, payload); None at a clean stream end.
pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
read_capped(input, MAX_FRAME)
}
/// As `read_frame`, refusing payloads over `cap` before allocating.
pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
let mut len = [0u8; 4];
// Only a stream ending between frames ends cleanly.
let mut got = 0;
@@ -66,10 +71,10 @@ pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)
}
}
let len = u32::from_le_bytes(len) as usize;
if len > MAX_FRAME {
if len > cap {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame over the cap",
format!("a {len} byte frame is over the {cap} byte cap"),
));
}
let mut tag = [0u8; 1];
+1
View File
@@ -32,6 +32,7 @@ windows-sys.workspace = true
tempfile.workspace = true
ely-ui.workspace = true
gpui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
[[example]]
name = "lokview"
+9
View File
@@ -237,6 +237,15 @@ impl Worker {
Ok(())
}
/// Turns `step` parts, or posts `key` if something is picked.
pub fn turn(&mut self, doc: &Doc, step: i32, key: Option<(u32, i32)>) -> io::Result<Option<usize>> {
let (char_code, key_code) = key.unwrap_or_default();
let doc_id = self.doc_id(doc)?;
let payload = Pack::new().u64(doc_id).i64(step.into()).i64(char_code.into()).i64(key_code.into()).bytes();
let shown = Unpack::new(&self.request(tag::TURN, &payload)?).i64()?;
Ok(usize::try_from(shown).ok())
}
/// Drags a selected object's handle, start then end.
pub fn drag_graphic(&mut self, doc: &Doc, from: (i32, i32), to: (i32, i32)) -> io::Result<()> {
for (kind, (x, y)) in [(0, from), (1, to)] {
+2
View File
@@ -48,6 +48,7 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
selection: marked,
};
let cell = crate::events::Cell::unpack(&mut unpack)?;
let table = crate::table::Table::unpack(&mut unpack)?;
let events = Events {
seq,
revision,
@@ -72,6 +73,7 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
a11y,
tabs,
cell,
table,
};
Ok((id, events))
}
+2
View File
@@ -3,6 +3,7 @@ use std::path::PathBuf;
use super::*;
mod analysis;
mod borders;
mod charts;
mod check;
mod commands;
@@ -15,6 +16,7 @@ pub(crate) mod input;
mod latency;
mod layout;
mod media;
mod menus;
mod motion;
mod output;
mod paint;
@@ -0,0 +1,58 @@
use super::input::open;
use super::menus::{DOC, find};
use super::*;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// The first inner column line, once it differs from `from`.
fn column(worker: &mut Worker, doc: &Doc, seen: &Events, from: i32) -> Events {
poll_until(worker, doc, seen, WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.inner[0].position != from)
})
}
#[test]
fn a_border_reports_and_moves_in_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(DOC);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let (x, y) = find(&mut worker, &doc, &mut seen, "left");
click(&mut worker, &doc, x, y);
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.table.is_some());
let table = seen.table.clone().expect("the cursor's table");
let (columns, rows) = (&table.columns, &table.rows);
assert_eq!(columns.inner.len(), 1, "two columns, one inner line");
assert_eq!(rows.inner.len(), 1, "two rows, one inner line");
let caret = seen.cursor.expect("a caret");
let across = (columns.offset + columns.left, columns.offset + columns.inner[0].position);
let down = (rows.offset + rows.left, rows.offset + rows.inner[0].position);
assert!(across.0 < caret.0 && caret.0 < across.1, "the caret is in column one: {across:?} {caret:?}");
assert!(down.0 <= caret.1 && caret.1 < down.1, "the caret is in row one: {down:?} {caret:?}");
let read = worker.now(&doc).expect("the revision");
let line = columns.inner[0].position;
worker.run(&read, "table.border", &["column-middle", "0", "500"]).expect("the line moves");
seen = column(&mut worker, &doc, &seen, line);
let moved = seen.table.clone().expect("still in the table");
assert_eq!(moved.columns.inner[0].position, line + 500, "500 twips right");
let stale = worker.run(&read, "table.border", &["column-middle", "0", "500"]);
assert!(stale.is_err_and(|error| crate::is_stale(&error)), "an old read is refused");
worker.run(&doc, "edit.undo", &[]).expect("undo");
seen = column(&mut worker, &doc, &seen, line + 500);
let undone = seen.table.clone().expect("still in the table");
assert_eq!(undone.columns.inner[0].position, line, "one undo restores it");
let bottom = undone.rows.right;
let read = worker.now(&doc).expect("the revision");
worker.run(&read, "table.border", &["row-right", "0", "400"]).expect("the bottom moves");
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.rows.right != bottom)
});
let taller = seen.table.expect("still in the table");
assert_eq!(taller.rows.right, bottom + 400, "the last row grows");
}
+1 -1
View File
@@ -143,7 +143,7 @@ fn every_mapped_command_reaches_the_kernel_and_reports_state() {
assert!(text.contains("fo:font-weight=\"bold\""), "bold landed");
assert_eq!(
commands::all().count(),
246,
248,
"the rows of text, layout, refs, tools, sheet, calc, data, analysis, page, show and draw"
);
}
+116
View File
@@ -0,0 +1,116 @@
use super::input::open;
use super::layout::png;
use super::*;
use crate::menu::{self, Target};
pub(super) const DOC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>The cat ran.</text:p>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Right-clicks at a point and reads the kernel's menu.
fn right(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
click(worker, doc, x, y);
press(worker, doc, seen, (x, y))
}
/// Right-clicks without moving the selection first.
fn press(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
for kind in [0, 1] {
let event = Mouse { kind, x, y, count: 1, buttons: 4, modifiers: 0 };
worker.post_mouse(doc, event).expect("the click posts");
}
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.menu.is_some());
menu::read(&seen.menu.take().expect("a menu")).expect("the menu parses")
}
/// The middle of the first match of `word`.
pub(super) fn find(worker: &mut Worker, doc: &Doc, seen: &mut Events, word: &str) -> (i32, i32) {
let before = seen.searched;
worker
.run(doc, "find.run", &[word, "false", "0", "0", "0", "1"])
.expect("the search runs");
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.searched > before);
let (x, y, _, h) = seen.found[0];
(x + 60, y + h / 2)
}
#[test]
fn a_right_click_names_its_target() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(DOC);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let at = find(&mut worker, &doc, &mut seen, "cat");
let text = right(&mut worker, &doc, &mut seen, at);
assert_eq!(text.target, Target::Text);
assert!(text.allows(".uno:InsertAnnotation") && text.allows(".uno:Paste"));
assert!(!text.allows(".uno:Cut"), "nothing is selected");
let at = find(&mut worker, &doc, &mut seen, "left");
let table = right(&mut worker, &doc, &mut seen, at);
assert_eq!(table.target, Target::Table);
for uno in [
".uno:InsertRowsBefore",
".uno:InsertColumnsAfter",
".uno:DeleteTable",
".uno:SplitCell",
] {
assert!(table.allows(uno), "{uno} runs in a cell");
}
assert!(!table.allows(".uno:MergeCells"), "one cell does not merge");
let at = find(&mut worker, &doc, &mut seen, "after");
click(&mut worker, &doc, at.0, at.1);
let blue = png(dir.path(), "blue.png", [0, 0, 255, 255]);
worker.run(&doc, "image.insert", &[&blue]).expect("the image lands");
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.graphic.is_some());
let (x, y, w, h) = seen.graphic.expect("the image is selected");
let frame = right(&mut worker, &doc, &mut seen, (x + w / 2, y + h / 2));
assert_eq!(frame.target, Target::Frame);
for uno in [".uno:Copy", ".uno:WrapOn", ".uno:BringToFront", ".uno:InsertCaptionDialog"] {
assert!(frame.allows(uno), "{uno} runs on an image");
}
assert!(!frame.allows(".uno:InsertAnnotation"), "no comment on an image");
}
const LOCKED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>open words</text:p>
<text:section text:name="S" text:protected="true"><text:p>sealed words</text:p></text:section>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell table:protected="true"><text:p>fixed</text:p></table:table-cell><table:table-cell><text:p>free</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
#[test]
fn protected_text_offers_no_link() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(LOCKED);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
for (word, target, link) in [
("open", Target::Text, true),
("sealed", Target::Text, false),
("free", Target::Table, true),
("fixed", Target::Table, false),
] {
let at = find(&mut worker, &doc, &mut seen, word);
let offered = press(&mut worker, &doc, &mut seen, at);
assert_eq!(offered.target, target, "{word}");
assert_eq!(offered.allows(".uno:InsertHyperlink"), link, "{word} selected");
}
let at = find(&mut worker, &doc, &mut seen, "open");
let caret = right(&mut worker, &doc, &mut seen, at);
assert!(!caret.allows(".uno:InsertHyperlink"), "the kernel links a selection");
}
+5 -4
View File
@@ -1,6 +1,6 @@
use super::input::{open, saved};
use super::*;
use crate::review;
use crate::{menu, review};
const TEXT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
@@ -125,8 +125,9 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
let spelling = review::spelling(&json)
let spelling = menu::read(&json)
.expect("the menu parses")
.spelling
.expect("a spelling menu");
let (_, rule) = spelling
.suggestions
@@ -145,13 +146,13 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert!(review::spelling(&json).expect("parses").is_some());
assert!(menu::read(&json).expect("parses").spelling.is_some());
worker
.run(&doc, "spell.ignore_all", &["Spelling"])
.expect("ignore all");
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert_eq!(
review::spelling(&json).expect("parses"),
menu::read(&json).expect("parses").spelling,
None,
"an ignored word is no longer wrong"
);
+1
View File
@@ -153,6 +153,7 @@ pub const QUERIES: &[&str] = &[
crate::sheet::NUMBER,
crate::sheet::FILTER,
crate::sheet::LIST,
crate::menu::STATES,
];
/// The kernel's font families, from `.uno:CharFontName`.
+10
View File
@@ -159,6 +159,16 @@ pub(super) const ROWS: &[Command] = &[
&[],
Check::Undoable,
),
command(
"table.border",
".uno:TableChangeCurrentBorderPosition",
&[
Arg::Text("BorderType"),
Arg::Int("Index", "unsigned short", 0, 999),
Arg::Int("Offset", "long", -600_000, 600_000),
],
Check::Undoable,
),
command(
"table.borders_all",
".uno:SetBorderStyle",
+1
View File
@@ -100,6 +100,7 @@ pub(super) const ROWS: &[Command] = &[
Check::Undoable,
),
command("cells.split", ".uno:SplitCell", &[], Check::Undoable),
command("cells.clear", ".uno:ClearContents", &[], Check::Undoable),
command("view.freeze", ".uno:FreezePanes", &[], Check::Finished),
command("rows.hide", ".uno:HideRow", &[], Check::Undoable),
command("rows.show", ".uno:ShowRow", &[], Check::Undoable),
+14 -12
View File
@@ -54,6 +54,7 @@ const CELL_ADDRESS: i32 = 34;
const REFERENCE_MARKS: i32 = 45;
const FUNCTION_LIST: i32 = 47;
const TOOLTIP: i32 = 71;
const TABLE: i32 = 44;
/// A mixed state; kernel values never hold NUL.
pub const MIXED: &str = "\u{0}mixed";
@@ -103,6 +104,7 @@ pub struct Events {
/// The paragraph's own tab stops, in 1/100 mm.
pub tabs: Vec<i32>,
pub cell: Cell,
pub table: Option<crate::table::Table>,
}
#[derive(Default)]
@@ -144,7 +146,8 @@ fn signal() -> &'static Condvar {
SIGNAL.get_or_init(Condvar::new)
}
const KINDS: [i32; 34] = [
const KINDS: [i32; 35] = [
TABLE,
MOUSE_POINTER,
REFERENCE_MARKS,
FUNCTION_LIST,
@@ -266,6 +269,7 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
.push(format!("an unreadable search result: {text}")),
},
MENU => batch.menu = Some(text.to_string()),
TABLE => crate::table::read(text).map_or_else(|fault| batch.faults.push(fault), |table| batch.table = table),
CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text),
CELL_FORMULA => batch.cell.formula = text.to_string(),
CELL_ADDRESS => batch.cell.address = text.to_string(),
@@ -358,11 +362,14 @@ pub fn stop_all(docs: &HashMap<u64, Document>) {
/// The current generation of `id`.
pub fn generation(id: u64) -> u64 {
slots()
.lock()
.expect("the events lock")
.get(&id)
.map_or(0, |slot| slot.batch.generation)
slots().lock().expect("the events lock").get(&id).map_or(0, |slot| slot.batch.generation)
}
/// Whether `id` shows a selection, object or caret.
pub fn picked(id: u64) -> bool {
let slots = slots().lock().expect("the events lock");
let Some(batch) = slots.get(&id).map(|slot| &slot.batch) else { return false };
batch.graphic.is_some() || !batch.selection.is_empty() || batch.cursor_visible && batch.cursor.is_some()
}
/// Blocks until documents have unpushed events.
@@ -395,12 +402,7 @@ pub fn clear_result(id: u64) {
/// The completion the last dispatch reported.
pub fn take_result(id: u64) -> Option<(String, bool)> {
slots()
.lock()
.expect("the events lock")
.get_mut(&id)?
.result
.take()
slots().lock().expect("the events lock").get_mut(&id)?.result.take()
}
/// The bridged a11y text now.
+7
View File
@@ -60,6 +60,8 @@ macro_rules! call {
}};
}
#[cfg(target_os = "macos")]
mod accessory;
mod document;
mod slideshow;
pub use document::{Raw, close_window};
@@ -80,6 +82,9 @@ impl Lok {
let install_c = cstring(&install_str)?;
let profile_c = cstring(profile_url)?;
let lib = Library::open(install)?;
// Before the kit starts, so no Dock icon shows.
#[cfg(target_os = "macos")]
accessory::hide()?;
let hook: Hook2 = unsafe {
lib.symbol(b"libreofficekit_hook_2\0")
.or_else(|_| lib.symbol(b"libreofficekit_hook\0"))
@@ -97,6 +102,8 @@ impl Lok {
"lok hook failed for {install_str}"
)));
}
#[cfg(target_os = "macos")]
accessory::check()?;
log::info!("lok: hooked {}", install_str);
Ok(Self { kit, _lib: lib })
}
+81
View File
@@ -0,0 +1,81 @@
//! The worker stays out of the Dock (macOS).
use std::ffi::{c_char, c_void};
use std::io;
#[link(name = "Foundation", kind = "framework")]
#[link(name = "objc")]
unsafe extern "C" {
fn objc_getClass(name: *const c_char) -> *mut c_void;
fn sel_registerName(name: *const c_char) -> *mut c_void;
fn objc_msgSend();
}
/// NSApplicationActivationPolicyAccessory: no Dock icon, no menu bar.
const ACCESSORY: isize = 1;
/// NSApplicationActivationPolicyProhibited: an unbundled worker starts so.
const PROHIBITED: isize = 2;
type Id = *mut c_void;
fn send() -> unsafe extern "C" fn() {
objc_msgSend as unsafe extern "C" fn()
}
fn class(name: &std::ffi::CStr) -> io::Result<Id> {
let class = unsafe { objc_getClass(name.as_ptr()) };
if class.is_null() {
return Err(io::Error::other(format!("no {} class", name.to_string_lossy())));
}
Ok(class)
}
fn get(object: Id, selector: &std::ffi::CStr) -> io::Result<Id> {
type F = unsafe extern "C" fn(Id, Id) -> Id;
let f: F = unsafe { std::mem::transmute(send()) };
let result = unsafe { f(object, sel_registerName(selector.as_ptr())) };
if result.is_null() {
return Err(io::Error::other(format!("{} gave nothing", selector.to_string_lossy())));
}
Ok(result)
}
/// Marks the process a UI element before registration.
pub fn hide() -> io::Result<()> {
// The kit reads the bundle's LSUIElement when it registers.
let info = get(get(class(c"NSBundle")?, c"mainBundle")?, c"infoDictionary")?;
type Text = unsafe extern "C" fn(Id, Id, *const c_char) -> Id;
type Flag = unsafe extern "C" fn(Id, Id, i8) -> Id;
type Set = unsafe extern "C" fn(Id, Id, Id, Id);
let text: Text = unsafe { std::mem::transmute(send()) };
let flag: Flag = unsafe { std::mem::transmute(send()) };
let set: Set = unsafe { std::mem::transmute(send()) };
unsafe {
let key = text(class(c"NSString")?, sel_registerName(c"stringWithUTF8String:".as_ptr()), c"LSUIElement".as_ptr());
let yes = flag(class(c"NSNumber")?, sel_registerName(c"numberWithBool:".as_ptr()), 1);
set(info, sel_registerName(c"setObject:forKey:".as_ptr()), yes, key);
}
Ok(())
}
/// Fails unless the kernel's application came up an accessory.
pub fn check() -> io::Result<()> {
type Policy = unsafe extern "C" fn(Id, Id) -> isize;
type SetPolicy = unsafe extern "C" fn(Id, Id, isize) -> i8;
let policy: Policy = unsafe { std::mem::transmute(send()) };
let set: SetPolicy = unsafe { std::mem::transmute(send()) };
let app = get(class(c"NSApplication")?, c"sharedApplication")?;
let read = || unsafe { policy(app, sel_registerName(c"activationPolicy".as_ptr())) };
let mut found = read();
if found == PROHIBITED {
// Outside a bundle nothing registers; accessory lets it draw.
let done = unsafe { set(app, sel_registerName(c"setActivationPolicy:".as_ptr()), ACCESSORY) };
found = read();
log::info!("lok: the unbundled worker asked for accessory ({done})");
}
if found != ACCESSORY {
return Err(io::Error::other(format!("the worker's activation policy is {found}, not accessory")));
}
log::info!("lok: the worker is an accessory app");
Ok(())
}
+2
View File
@@ -88,6 +88,8 @@ pub mod tag {
pub const REVISION: u8 = 34;
/// One layer's pixels from the last `SLIDE_LAYERS`.
pub const SLIDE_LAYER: u8 = 35;
/// Turns parts; an arrow with something picked goes in.
pub const TURN: u8 = 36;
pub const OK: u8 = 100;
pub const ERR: u8 = 101;
}
+3 -1
View File
@@ -7,9 +7,11 @@ mod ffi;
mod handshake;
pub mod history;
mod ipc;
pub mod menu;
pub mod review;
pub mod sheet;
pub mod show;
mod table;
mod worker;
pub use client::{
@@ -18,7 +20,7 @@ pub use client::{
};
#[cfg(feature = "view")]
pub use view::{
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS,
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS, notch,
Zoomed, init, layout, paper, read_off, scheme,
};
#[cfg(feature = "view")]
+156
View File
@@ -0,0 +1,156 @@
//! The kernel's context menu: target and flags (DOC-02).
use std::{collections::HashMap, io};
use crate::{
Doc, Worker,
review::{entries, invalid, parse},
};
/// Where a right click landed, per the kernel's menu.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Target {
Text,
Table,
/// A Writer graphic, object or frame.
Frame,
/// A spreadsheet cell.
Cell,
/// A drawing object outside Writer.
Object,
/// A picture outside Writer.
Picture,
/// An Impress slide, nothing selected.
Slide,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// One menu the kernel offered.
#[derive(Clone, Debug, PartialEq)]
pub struct Offered {
pub target: Target,
pub spelling: Option<Spelling>,
/// Each command, at any depth, and whether it runs now.
enabled: HashMap<String, bool>,
}
impl Offered {
/// The kernel offers `uno` and it runs now.
pub fn allows(&self, uno: &str) -> bool {
self.enabled.get(uno) == Some(&true)
}
}
/// Which asked commands run now, per the kernel's dispatcher.
#[derive(Clone, Debug, PartialEq)]
pub struct Runs(HashMap<String, bool>);
impl Runs {
/// The kernel lets `uno` run now.
pub fn allows(&self, uno: &str) -> bool {
self.0.get(uno) == Some(&true)
}
}
/// The read of which commands run now (patch 0045).
pub const STATES: &str = ".uno:CommandStates";
/// Asks the kernel which of `commands` run now.
pub fn runs(worker: &mut Worker, doc: &Doc, commands: &[&str]) -> io::Result<Runs> {
let json = worker.values(doc, &format!("{STATES}?commands={}", commands.join(",")))?;
read_runs(&json, commands)
}
/// Reads the answer; every asked command must be in it.
fn read_runs(json: &str, commands: &[&str]) -> io::Result<Runs> {
let value = parse(json)?;
let states = value["commandStates"]
.as_object()
.ok_or_else(|| invalid(format!("no command states: {json}")))?;
let read = |command: &&str| match states.get(*command).and_then(serde_json::Value::as_bool) {
Some(on) => Ok((command.to_string(), on)),
None => Err(invalid(format!("no state for {command}: {json}"))),
};
Ok(Runs(commands.iter().map(read).collect::<io::Result<_>>()?))
}
/// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload.
pub fn read(json: &str) -> io::Result<Offered> {
let list = parse(json)?;
let mut enabled = HashMap::new();
let mut suggestions = Vec::new();
walk(entries(&list, "menu")?, &mut enabled, &mut suggestions)?;
let spelling = enabled
.keys()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
.then_some(Spelling { suggestions });
let target = if enabled.contains_key(".uno:TableInsertMenu") {
Target::Table
} else if enabled.contains_key(".uno:WrapMenu") {
Target::Frame
} else if enabled.contains_key(".uno:InsertCell") {
Target::Cell
} else if enabled.contains_key(".uno:ChangePicture") {
Target::Picture
} else if enabled.contains_key(".uno:ArrangeMenu") {
Target::Object
} else if enabled.contains_key(".uno:SlideLayoutMenu") {
Target::Slide
} else {
Target::Text
};
Ok(Offered {
target,
spelling,
enabled,
})
}
fn walk(
items: &[serde_json::Value],
enabled: &mut HashMap<String, bool>,
suggestions: &mut Vec<(String, String)>,
) -> io::Result<()> {
for item in items {
if item["type"] == "separator" {
continue;
}
let on = match item["enabled"].as_str() {
Some("true") => true,
Some("false") => false,
_ => return Err(invalid(format!("a menu item has no enabled flag: {item}"))),
};
// A submenu may have no command.
match (item["command"].as_str(), item["type"].as_str()) {
(Some(command), _) => {
enabled.insert(command.to_string(), on);
if let Some(rule) =
command.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")
{
let label = item["text"]
.as_str()
.ok_or_else(|| invalid(format!("a suggestion has no text: {item}")))?;
suggestions.push((label.to_string(), rule.to_string()));
}
}
(None, Some("menu")) => {}
(None, _) => return Err(invalid(format!("a menu item has no command: {item}"))),
}
if let Some(sub) = item.get("menu") {
let sub = sub
.as_array()
.ok_or_else(|| invalid(format!("a submenu is no list: {item}")))?;
walk(sub, enabled, suggestions)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests;
+64
View File
@@ -0,0 +1,64 @@
use super::*;
fn item(command: &str, enabled: bool) -> String {
format!(r#"{{"text":"x","type":"command","command":"{command}","enabled":"{enabled}"}}"#)
}
#[test]
fn spelling_rows_come_first_class() {
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The","enabled":"true"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling","enabled":"true"}]}"#;
let offered = read(menu).expect("the menu parses");
let spelling = offered.spelling.expect("a spelling menu");
assert_eq!(spelling.suggestions, [("The".into(), "Spelling_The".into())]);
assert_eq!(offered.target, Target::Text);
let other = format!(r#"{{"menu":[{}]}}"#, item(".uno:Cut", true));
assert_eq!(read(&other).expect("the menu parses").spelling, None);
}
#[test]
fn the_menu_names_its_target_and_flags() {
let table = format!(
r#"{{"menu":[{},{{"text":"Insert","type":"menu","command":".uno:TableInsertMenu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:MergeCells", false),
item(".uno:InsertRowsBefore", true),
);
let table = read(&table).expect("the table menu parses");
assert_eq!(table.target, Target::Table);
assert!(table.allows(".uno:InsertRowsBefore"), "submenu rows count");
assert!(!table.allows(".uno:MergeCells"), "a disabled row");
assert!(!table.allows(".uno:DeleteTable"), "an absent row");
let frame = format!(
r#"{{"menu":[{{"type":"menu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:WrapMenu", true)
);
assert_eq!(read(&frame).expect("a commandless submenu").target, Target::Frame);
let cell = format!(r#"{{"menu":[{}]}}"#, item(".uno:InsertCell", false));
assert_eq!(read(&cell).expect("parses").target, Target::Cell);
let object = format!(r#"{{"menu":[{}]}}"#, item(".uno:ArrangeMenu", true));
assert_eq!(read(&object).expect("parses").target, Target::Object);
let text = format!(r#"{{"menu":[{}]}}"#, item(".uno:Paste", true));
assert_eq!(read(&text).expect("parses").target, Target::Text);
// Impress: a picture also arranges; a bare slide has layouts.
let picture = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:ChangePicture", true), item(".uno:ArrangeMenu", true));
assert_eq!(read(&picture).expect("parses").target, Target::Picture);
let slide = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:Paste", true), item(".uno:SlideLayoutMenu", true));
assert_eq!(read(&slide).expect("parses").target, Target::Slide);
}
#[test]
fn a_malformed_menu_fails() {
assert!(read(r#"{"menu":[{"type":"command","command":".uno:Cut"}]}"#).is_err());
assert!(read(r#"{"menu":[{"type":"command","enabled":"true"}]}"#).is_err());
assert!(read(r#"{"items":[]}"#).is_err());
}
#[test]
fn command_states_answer_each_asked_command() {
let json = r#"{"commandStates":{".uno:DeleteRows":false,".uno:InsertRowsBefore":true}}"#;
let runs = read_runs(json, &[".uno:InsertRowsBefore", ".uno:DeleteRows"]).expect("parses");
assert!(runs.allows(".uno:InsertRowsBefore"));
assert!(!runs.allows(".uno:DeleteRows"));
assert!(read_runs(json, &[".uno:HideRow"]).is_err(), "an unanswered command");
let text = r#"{"commandStates":{".uno:Cut":"true"}}"#;
assert!(read_runs(text, &[".uno:Cut"]).is_err(), "a state is a boolean");
}
+3 -43
View File
@@ -38,13 +38,6 @@ pub struct Counts {
pub selected_characters: u64,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// The counts from `.uno:WordCount`.
pub fn counts(json: &str) -> io::Result<Counts> {
let list = parse(json)?;
@@ -57,32 +50,6 @@ pub fn counts(json: &str) -> io::Result<Counts> {
})
}
/// A context menu's spelling part, if it has one.
pub fn spelling(json: &str) -> io::Result<Option<Spelling>> {
let list = parse(json)?;
let items = entries(&list, "menu")?;
let commands: Vec<&str> = items
.iter()
.filter_map(|item| item["command"].as_str())
.collect();
if !commands
.iter()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
{
return Ok(None);
}
let suggestions = items
.iter()
.filter_map(|item| {
let rule = item["command"]
.as_str()?
.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")?;
Some((item["text"].as_str()?.to_string(), rule.to_string()))
})
.collect();
Ok(Some(Spelling { suggestions }))
}
/// A heading from `.uno:Outline`.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
@@ -190,11 +157,11 @@ pub fn formula(json: &str) -> io::Result<Option<String>> {
}
}
fn parse(json: &str) -> io::Result<serde_json::Value> {
pub(crate) fn parse(json: &str) -> io::Result<serde_json::Value> {
serde_json::from_str(json).map_err(|error| invalid(format!("a bad kernel list: {error}")))
}
fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
pub(crate) fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
list[key]
.as_array()
.ok_or_else(|| invalid(format!("the list has no {key}")))
@@ -257,7 +224,7 @@ fn plain(html: &str) -> String {
.replace("&amp;", "&")
}
fn invalid(message: String) -> io::Error {
pub(crate) fn invalid(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -288,12 +255,5 @@ mod tests {
let counts = counts(r#"{"document":{"words":11,"characters":41,"charactersNoSpaces":31},"selection":{"words":2,"characters":7,"charactersNoSpaces":6}}"#)
.expect("counts parse");
assert_eq!((counts.words, counts.selected_characters), (11, 7));
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling"}]}"#;
let found = spelling(menu)
.expect("the menu parses")
.expect("a spelling menu");
assert_eq!(found.suggestions, [("The".into(), "Spelling_The".into())]);
let other = r#"{"menu":[{"text":"Cut","type":"command","command":".uno:Cut"}]}"#;
assert_eq!(spelling(other).expect("the menu parses"), None);
}
}
+196
View File
@@ -0,0 +1,196 @@
//! A table's borders, as Writer and Impress report them (DOC-07).
use std::io;
use serde_json::Value;
use crate::ipc::{Pack, Unpack};
/// One axis in twips: columns run across, rows down.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Lines {
/// The first line, from `offset`.
pub left: i32,
/// The last line, from `offset`.
pub right: i32,
pub offset: i32,
pub inner: Vec<Line>,
/// Bounds of the first and last lines, when sent.
pub ends: Option<[(i32, i32); 2]>,
}
/// An inner line and the kernel's bounds for it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Line {
pub position: i32,
pub min: i32,
pub max: i32,
pub hidden: bool,
}
/// The table holding the cursor.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Table {
pub columns: Lines,
pub rows: Lines,
}
/// Reads a `LOK_CALLBACK_TABLE_SELECTED` payload; `{}` is no table.
pub fn read(text: &str) -> Result<Option<Table>, String> {
let fault = || format!("an unreadable table: {text}");
let json: Value = serde_json::from_str(text).map_err(|_| fault())?;
let object = json.as_object().ok_or_else(fault)?;
if object.is_empty() {
return Ok(None);
}
// Impress writes every value as text (boost's property tree).
let int = |value: Option<&Value>| match value? {
Value::Number(number) => i32::try_from(number.as_i64()?).ok(),
Value::String(text) => text.parse().ok(),
_ => None,
};
let flag = |value: Option<&Value>| match value? {
Value::Bool(on) => Some(*on),
Value::String(text) => text.parse().ok(),
_ => None,
};
let lines = |key: &str| -> Option<Lines> {
let axis = object.get(key)?;
// Boost writes an empty list as an empty string.
let entries = match axis.get("entries")? {
Value::Array(entries) => entries.as_slice(),
Value::String(text) if text.is_empty() => &[],
_ => return None,
};
let inner = entries.iter().map(|entry| {
Some(Line {
position: int(entry.get("position"))?,
min: int(entry.get("min"))?,
max: int(entry.get("max"))?,
hidden: flag(entry.get("hidden"))?,
})
});
let bound = |key: &str| int(axis.get(key));
let ends = match ["leftMin", "leftMax", "rightMin", "rightMax"].map(|key| axis.get(key).is_some()) {
[false, false, false, false] => None,
[true, true, true, true] => {
Some([(bound("leftMin")?, bound("leftMax")?), (bound("rightMin")?, bound("rightMax")?)])
}
_ => return None,
};
Some(Lines {
left: int(axis.get("left"))?,
right: int(axis.get("right"))?,
offset: int(axis.get("tableOffset"))?,
inner: inner.collect::<Option<_>>()?,
ends,
})
};
let columns = lines("columns").ok_or_else(fault)?;
let rows = lines("rows").ok_or_else(fault)?;
Ok(Some(Table { columns, rows }))
}
impl Table {
pub(crate) fn pack(table: Option<&Table>, pack: Pack) -> Pack {
let Some(table) = table else {
return pack.u64(0);
};
[&table.columns, &table.rows].into_iter().fold(pack.u64(1), |pack, axis| {
let pack = pack
.i64(axis.left.into())
.i64(axis.right.into())
.i64(axis.offset.into())
.u64(axis.inner.len() as u64);
let pack = match axis.ends {
None => pack.u64(0),
Some(ends) => ends.iter().fold(pack.u64(1), |pack, &(low, high)| pack.i64(low.into()).i64(high.into())),
};
axis.inner.iter().fold(pack, |pack, line| {
pack.i64(line.position.into())
.i64(line.min.into())
.i64(line.max.into())
.u64(line.hidden as u64)
})
})
}
pub(crate) fn unpack(unpack: &mut Unpack) -> io::Result<Option<Table>> {
if unpack.u64()? == 0 {
return Ok(None);
}
let mut lines = || -> io::Result<Lines> {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
let (left, right, offset) = (int()?, int()?, int()?);
let count = unpack.u64()?;
let ends = match unpack.u64()? {
0 => None,
_ => {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
Some([(int()?, int()?), (int()?, int()?)])
}
};
let inner = (0..count)
.map(|_| {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
let (position, min, max) = (int()?, int()?, int()?);
Ok(Line { position, min, max, hidden: unpack.u64()? != 0 })
})
.collect::<io::Result<_>>()?;
Ok(Lines { left, right, offset, inner, ends })
};
let columns = lines()?;
let rows = lines()?;
Ok(Some(Table { columns, rows }))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SEEN: &str = r#"{ "columns": { "left": 0, "right": 9638, "tableOffset": 1135, "entries": [ { "position": 4819, "min": 300, "max": 9338, "hidden": false } ] }, "rows": { "left": 0, "right": 552, "tableOffset": 1418, "entries": [ { "position": 276, "min": 100, "max": 552, "hidden": false } ] } }"#;
/// As Impress sent it for a new 2x2 table.
const IMPRESS: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "entries": [ { "position": "3996", "min": "340", "max": "7653", "hidden": "false" } ] }, "rows": { "tableOffset": "4340", "left": "0", "right": "1091", "entries": [ { "position": "545", "min": "340", "max": "152183880", "hidden": "false" } ] }, "rectangle": { "x": "3940", "y": "4351", "width": "7993", "height": "1091" } }"#;
#[test]
fn impress_writes_numbers_as_text() {
let table = read(IMPRESS).expect("readable").expect("a table");
assert_eq!(table.columns.offset, 3929);
assert_eq!(table.columns.inner, [Line { position: 3996, min: 340, max: 7653, hidden: false }]);
assert_eq!(table.rows.right, 1091);
assert!(read(&IMPRESS.replace("\"false\"", "\"no\"")).is_err(), "a flag is true or false");
assert_eq!(table.rows.ends, None, "an unpatched kernel sends no ends");
}
/// One row, as Impress sends it with patch 0047.
const ONE_ROW: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "leftMin": "-152183880", "leftMax": "7823", "rightMin": "170", "rightMax": "152183880", "entries": "" }, "rows": { "tableOffset": "4340", "left": "0", "right": "545", "leftMin": "-152183880", "leftMax": "28", "rightMin": "517", "rightMax": "152183880", "entries": "" } }"#;
#[test]
fn one_line_has_no_inner_lines_and_its_ends_cross_the_wire() {
let table = read(ONE_ROW).expect("readable").expect("a table");
assert!(table.columns.inner.is_empty() && table.rows.inner.is_empty());
assert_eq!(table.rows.ends, Some([(-152_183_880, 28), (517, 152_183_880)]));
assert!(read(&ONE_ROW.replace("\"entries\": \"\"", "\"entries\": \"x\"")).is_err(), "text is no list");
assert!(read(&ONE_ROW.replace("\"leftMin\": \"-152183880\", ", "")).is_err(), "ends come in fours");
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
assert_eq!(Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks"), Some(table));
}
#[test]
fn a_table_reads_and_crosses_the_wire() {
assert_eq!(read("{ }"), Ok(None), "no table");
let table = read(SEEN).expect("readable").expect("a table");
assert_eq!(table.columns.offset, 1135);
assert_eq!(table.rows.inner[0].position, 276);
let text = SEEN.replace("\"left\": 0,", "\"left\": \"zero\",");
assert!(read(&text).is_err(), "a word is no number");
assert!(read("[]").is_err());
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
let back = Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks");
assert_eq!(back, Some(table));
let none = Table::pack(None, Pack::new()).bytes();
assert_eq!(Table::unpack(&mut Unpack::new(&none)).expect("unpacks"), None);
}
}
+13 -17
View File
@@ -14,6 +14,7 @@ use image::{Frame, ImageBuffer, Rgba};
use crate::{Doc, Events, Rect, Worker};
mod borders;
mod clip;
mod clock;
mod formula;
@@ -31,7 +32,9 @@ mod scroll;
mod sheet;
pub use input::{CONTEXT, Copy, Cut, Paste, init};
pub use off::read_off;
pub use render::scheme;
pub use render::{paper, scheme};
mod turn;
pub use turn::notch;
pub(crate) use crate::{TILE_TWIPS, TWIPS_PER_PIXEL};
@@ -124,12 +127,15 @@ pub struct LokView {
dark: Option<bool>,
/// The zoom follows the viewport so a page fits.
fit: bool,
/// Set when the wheel and Up, Down turn parts.
turning: Option<turn::Turning>,
/// The sheet shown, and the offered function chosen.
part: usize,
picked: usize,
marked: Option<ime::Marked>,
dragging: bool,
grip: Option<handles::Grip>,
borders: borders::Borders,
handle_size: Pixels,
states: HashMap<String, String>,
graphic: Option<Rect>,
@@ -137,6 +143,7 @@ pub struct LokView {
/// The last search outcome: its count and matches.
search: (u64, Vec<Rect>),
menu: Option<String>,
right_at: Option<Point<Pixels>>,
fonts: Vec<String>,
a11y: crate::A11y,
tabs: Vec<i32>,
@@ -147,23 +154,11 @@ pub type Layout = ((i64, i64), Vec<Rect>);
use sheet::{Measured, measure};
/// A page's colour; the kernel's Ely schemes share it.
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
use ely_gpui_component::theme::ActiveTheme as _;
let theme = cx.theme();
if theme.is_dark() {
theme.colors.surface
} else {
theme.colors.paper
}
}
/// Reads `doc`'s size and page frames.
pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
Ok((doc.size(worker)?, doc.pages(worker)?))
}
impl EventEmitter<LokError> for LokView {}
impl EventEmitter<Reported> for LokView {}
@@ -191,10 +186,7 @@ impl LokView {
let mut view = Self {
worker,
doc,
geom: Geom {
zoom: 1.0,
dpi: 1.0,
},
geom: Geom { zoom: 1.0, dpi: 1.0 },
scroll: (0, 0),
doc_size: (width as i32, height as i32),
generation: 0,
@@ -228,17 +220,20 @@ impl LokView {
cell: crate::Cell::default(),
dark: None,
fit: false,
turning: None,
part: 0,
picked: 0,
marked: None,
dragging: false,
grip: None,
borders: Default::default(),
handle_size: px(6.0),
states: HashMap::new(),
graphic: None,
review: 0,
search: (0, Vec::new()),
menu: None,
right_at: None,
fonts: Vec::new(),
a11y: crate::A11y::default(),
tabs: Vec::new(),
@@ -398,6 +393,7 @@ impl LokView {
}
self.cell = batch.cell;
self.graphic = batch.graphic;
self.read_borders(batch.table, batch.revision);
self.review = batch.review;
self.search = (batch.searched, batch.found);
self.fonts = batch.fonts;
+293
View File
@@ -0,0 +1,293 @@
//! Dragging the cursor's table borders (DOC-07).
use gpui::{Bounds, Context, CursorStyle, Pixels, Point, px, size};
use super::LokView;
use crate::table::{Lines, Table};
use crate::{Doc, LokError};
/// Least gap the kernel keeps between lines, in twips.
const GAP: i32 = 40;
/// A border, as `.uno:TableChangeCurrentBorderPosition` names it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Side {
ColumnLeft,
Column(u16),
ColumnRight,
Row(u16),
RowBottom,
}
impl Side {
/// Column lines stand upright and move across.
fn upright(self) -> bool {
matches!(self, Side::ColumnLeft | Side::Column(_) | Side::ColumnRight)
}
fn kind(self) -> &'static str {
match self {
Side::ColumnLeft => "column-left",
Side::Column(_) => "column-middle",
Side::ColumnRight => "column-right",
Side::Row(_) => "row-middle",
Side::RowBottom => "row-right",
}
}
fn index(self) -> u16 {
match self {
Side::Column(index) | Side::Row(index) => index,
_ => 0,
}
}
}
/// A held border, with the table read it came from.
#[derive(Clone)]
pub(super) struct Held {
side: Side,
/// The pointer's offset from the line when pressed.
grab: i32,
from: i32,
to: i32,
table: Table,
doc: Doc,
}
/// The cursor's table as last read, and a held border.
#[derive(Default)]
pub(super) struct Borders {
table: Option<(Table, Doc)>,
/// The revision of the batch the table came from.
read: u64,
held: Option<Held>,
hover: Option<CursorStyle>,
}
/// Each border: side, place, span and room, in twips.
fn lines(table: &Table) -> Vec<(Side, i32, (i32, i32), i32)> {
let (columns, rows) = (&table.columns, &table.rows);
let down = (rows.offset + rows.left, rows.offset + rows.right);
let across = (columns.offset + columns.left, columns.offset + columns.right);
let mut upright = vec![(Side::ColumnLeft, across.0)];
upright.extend(inner(columns).map(|(index, at)| (Side::Column(index), at)));
upright.push((Side::ColumnRight, across.1));
let mut level: Vec<_> = inner(rows).map(|(index, at)| (Side::Row(index), at)).collect();
level.push((Side::RowBottom, down.1));
let mut lines = spaced(&upright, None, down);
lines.extend(spaced(&level, Some(down.0), across));
lines
}
/// Gives each line its span and nearest neighbour's distance.
fn spaced(stops: &[(Side, i32)], before: Option<i32>, span: (i32, i32)) -> Vec<(Side, i32, (i32, i32), i32)> {
let places: Vec<i32> = before.into_iter().chain(stops.iter().map(|&(_, at)| at)).collect();
let skip = places.len() - stops.len();
let room = |i: usize| {
let prev = i.checked_sub(1).map(|j| places[i] - places[j]);
prev.into_iter().chain(places.get(i + 1).map(|next| next - places[i])).min().unwrap_or(i32::MAX)
};
stops.iter().enumerate().map(|(i, &(side, at))| (side, at, span, room(i + skip))).collect()
}
fn inner(lines: &Lines) -> impl Iterator<Item = (u16, i32)> + '_ {
lines.inner.iter().enumerate().filter(|(_, line)| !line.hidden).map(|(index, line)| (index as u16, lines.offset + line.position))
}
/// `offset` as the kernel will clamp it.
fn clamp(table: &Table, side: Side, offset: i32) -> i32 {
let axis = if side.upright() { &table.columns } else { &table.rows };
let (first, last) = (axis.inner.first(), axis.inner.last());
// Impress sends its outer bounds; Writer leaves a gap.
let (at, low, high) = match (side, axis.ends) {
(Side::ColumnLeft, Some([(low, high), _])) => (axis.left, low, high),
(Side::ColumnRight | Side::RowBottom, Some([_, (low, high)])) => (axis.right, low, high),
(Side::ColumnLeft, None) => (axis.left, i32::MIN, first.map_or(axis.right, |line| line.position) - GAP),
(Side::ColumnRight | Side::RowBottom, None) => {
(axis.right, last.map_or(axis.left, |line| line.position) + GAP, i32::MAX)
}
(Side::Column(index) | Side::Row(index), ends) => {
let line = axis.inner[usize::from(index)];
let high = if ends.is_some() { line.max } else { (line.max - GAP).max(line.min) };
(line.position, line.min, high)
}
};
let moved = (at.saturating_add(offset)).clamp(low, high) - at;
// A line past its bound never moves against the drag.
if moved.signum() == offset.signum() { moved } else { 0 }
}
impl LokView {
/// Takes the cursor's table from a batch at `revision`.
pub(super) fn read_borders(&mut self, table: Option<Table>, revision: u64) {
if revision < self.borders.read {
log::debug!("lok: table read at {revision} is older than {}", self.borders.read);
return;
}
if table.is_none() {
self.borders.hover = None;
}
let mut doc = self.doc.clone();
doc.rev = revision;
self.borders.read = revision;
self.borders.table = table.map(|table| (table, doc));
}
/// The border under `position`; short cells keep their middles.
fn border_at(&self, position: Point<Pixels>) -> Option<(Side, i32)> {
let (table, _) = self.borders.table.as_ref()?;
lines(table).into_iter().find_map(|(side, at, (start, end), room)| {
let reach = self.handle_size.min(px(self.geom.px_of(room) / 4.0));
let (from, to) = if side.upright() {
(self.window_point((at, start)), self.window_point((at, end)))
} else {
(self.window_point((start, at)), self.window_point((end, at)))
};
let (near, along, low, high) = if side.upright() {
((from.x - position.x).abs(), position.y, from.y, to.y)
} else {
((from.y - position.y).abs(), position.x, from.x, to.x)
};
(near <= reach && along >= low - reach && along <= high + reach).then_some((side, at))
})
}
/// Holds the border under `position`, if any.
pub(super) fn hold_border(&mut self, position: Point<Pixels>) -> bool {
let Some(((side, at), (table, doc))) = self.border_at(position).zip(self.borders.table.clone()) else {
return false;
};
let (x, y) = self.doc_point(position);
let grab = if side.upright() { x } else { y } - at;
log::info!("lok: table border {} {} held", side.kind(), side.index());
self.borders.held = Some(Held { side, grab, from: at, to: at, table, doc });
true
}
/// A held border follows the pointer; a hovered one shows.
pub(super) fn move_border(&mut self, position: Point<Pixels>) -> bool {
let Some(held) = &self.borders.held else {
let side = self.border_at(position).map(|(side, _)| side);
self.borders.hover = side.map(|side| if side.upright() { CursorStyle::ResizeColumn } else { CursorStyle::ResizeRow });
return false;
};
let (x, y) = self.doc_point(position);
let want = if held.side.upright() { x } else { y } - held.grab;
let to = held.from + clamp(&held.table, held.side, want - held.from);
if let Some(held) = self.borders.held.as_mut() {
held.to = to;
}
true
}
/// Lets go at `position`: one command at the read's revision.
pub(super) fn drop_border(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) -> bool {
if !self.move_border(position) {
return false;
}
let Some(held) = self.borders.held.take() else {
return false;
};
let offset = held.to - held.from;
if offset == 0 {
log::info!("lok: table border let go in place");
return true;
}
log::info!("lok: table border {} {} moves {offset}", held.side.kind(), held.side.index());
let (index, offset) = (held.side.index().to_string(), offset.to_string());
if let Err(error) = self.run_at(&held.doc, "table.border", &[held.side.kind(), &index, &offset]) {
cx.emit(LokError(format!("the table border did not move: {error}")));
}
true
}
pub(super) fn holds_border(&self) -> bool {
self.borders.held.is_some()
}
/// The pointer a border asks for, while held or hovered.
pub(super) fn border_pointer(&self) -> Option<CursorStyle> {
match &self.borders.held {
Some(held) if held.side.upright() => Some(CursorStyle::ResizeColumn),
Some(_) => Some(CursorStyle::ResizeRow),
None => self.borders.hover,
}
}
/// The guide line of a held border, in window pixels.
pub(super) fn guide(&self) -> Option<Bounds<Pixels>> {
let held = self.borders.held.as_ref()?;
let (_, _, (start, end), _) = lines(&held.table).into_iter().find(|&(side, ..)| side == held.side)?;
Some(if held.side.upright() {
let (top, bottom) = (self.window_point((held.to, start)), self.window_point((held.to, end)));
Bounds { origin: top, size: size(px(1.0), bottom.y - top.y) }
} else {
let (left, right) = (self.window_point((start, held.to)), self.window_point((end, held.to)));
Bounds { origin: left, size: size(right.x - left.x, px(1.0)) }
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::table::Line;
fn table() -> Table {
let line = |position, min, max| Line { position, min, max, hidden: false };
Table {
columns: Lines { left: 0, right: 9000, offset: 1000, inner: vec![line(3000, 300, 6000), line(6000, 3300, 8700)], ends: None },
rows: Lines { left: 0, right: 800, offset: 2000, inner: vec![line(400, 100, 800)], ends: None },
}
}
#[test]
fn borders_lie_where_the_kernel_puts_them() {
let sides: Vec<_> = lines(&table()).into_iter().map(|(side, at, _, room)| (side, at, room)).collect();
assert_eq!(
sides,
[
(Side::ColumnLeft, 1000, 3000),
(Side::Column(0), 4000, 3000),
(Side::Column(1), 7000, 3000),
(Side::ColumnRight, 10_000, 3000),
(Side::Row(0), 2400, 400),
(Side::RowBottom, 2800, 400),
]
);
}
#[test]
fn a_drag_clamps_as_the_kernel_does() {
let table = table();
assert_eq!(clamp(&table, Side::Column(0), 500), 500, "inside its bounds");
assert_eq!(clamp(&table, Side::Column(0), 9000), 2960, "up to max less the gap");
assert_eq!(clamp(&table, Side::Column(1), -9000), -2700, "down to min");
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2960, "short of the first line");
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2960, "past the last line");
assert_eq!(clamp(&table, Side::RowBottom, -800), -360, "a row keeps the gap");
assert_eq!(clamp(&table, Side::Row(0), 500), 360, "max less the gap");
}
#[test]
fn sent_ends_bound_the_outer_lines() {
let mut table = table();
table.columns.ends = Some([(-50_000, 2800), (6200, 50_000)]);
table.rows.ends = Some([(-50_000, 380), (717, 50_000)]);
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2800, "to the first column's minimum");
assert_eq!(clamp(&table, Side::ColumnLeft, -900), -900, "outward too");
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2800, "to the last column's minimum");
assert_eq!(clamp(&table, Side::RowBottom, -800), -83, "to the last row's minimum");
assert_eq!(clamp(&table, Side::Column(0), 9000), 3000, "Impress's max needs no gap");
}
#[test]
fn a_line_at_or_past_its_bound_stays_put() {
let mut table = table();
table.columns.inner[0].max = 3000;
assert_eq!(clamp(&table, Side::Column(0), 0), 0, "a still press sends nothing");
assert_eq!(clamp(&table, Side::Column(0), 200), 0, "no room toward the bound");
assert_eq!(clamp(&table, Side::Column(0), -200), -200, "away from it is free");
}
}
+42 -11
View File
@@ -64,6 +64,14 @@ pub(super) enum Input {
Visible(Rect, (u32, i32)),
/// Shows sheet or slide `part`, from 0.
Part(usize),
/// Turns `step` parts; a picked object takes the arrow `key`.
Turn { step: i32, key: Option<(u32, i32)> },
}
/// What an input gives back.
pub(super) enum Back {
Copied(String),
Shown(usize),
}
impl Input {
@@ -73,6 +81,7 @@ impl Input {
// Arrows, Home, End, Page keys, Escape.
Input::Key { key_code, .. } => !matches!(key_code & 0x0FFF, 1024..=1031 | 1281),
Input::Mouse(_)
| Input::Turn { .. }
| Input::Copy { cut: false }
| Input::Visible(..)
| Input::Part(_)
@@ -169,7 +178,7 @@ fn lok_button(button: MouseButton) -> Option<i32> {
}
/// Runs one input; gives a copy's mark and the revision.
pub(super) fn run(worker: &Mutex<Worker>, doc: &Doc, input: Input) -> (io::Result<Option<String>>, Option<u64>) {
pub(super) fn run(worker: &Mutex<Worker>, doc: &Doc, input: Input) -> (io::Result<Option<Back>>, Option<u64>) {
let mut worker = worker.lock().expect("the worker lock");
let counts = !matches!(input, Input::Visible(..));
let copied = apply(&mut worker, doc, input);
@@ -181,12 +190,13 @@ pub(super) fn run(worker: &Mutex<Worker>, doc: &Doc, input: Input) -> (io::Resul
(copied, now)
}
fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<String>> {
fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Back>> {
let done = match input {
Input::Key {
char_code,
key_code,
} => worker.post_key(doc, char_code, key_code),
Input::Turn { step, key } => return Ok(worker.turn(doc, step, key)?.map(Back::Shown)),
Input::Mouse(event) => worker.post_mouse(doc, event),
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
Input::Visible(area, scale) => doc.visible_area(worker, area, scale),
@@ -195,9 +205,8 @@ fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Stri
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
Input::Copy { cut } => {
return clip::copy_out(worker, doc, cut, |clip| {
clipboard::write(clip).map_err(io::Error::other)
});
let copied = clip::copy_out(worker, doc, cut, |clip| clipboard::write(clip).map_err(io::Error::other));
return copied.map(|copied| copied.map(Back::Copied));
}
Input::Run { commands, at } => {
let values: Vec<Vec<&str>> =
@@ -298,7 +307,10 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.walk(&event.keystroke, cx) || self.key_down(&event.keystroke) {
if self.walk(&event.keystroke, cx)
|| self.turn_key(&event.keystroke)
|| self.key_down(&event.keystroke)
{
cx.stop_propagation();
}
}
@@ -313,7 +325,8 @@ impl LokView {
return;
};
window.focus(&self.focus, cx);
if event.button == MouseButton::Left && self.grab(event.position) {
self.right_at = (event.button == MouseButton::Right).then_some(event.position);
if event.button == MouseButton::Left && (self.grab(event.position) || self.hold_border(event.position)) {
cx.notify();
return;
}
@@ -333,6 +346,10 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.drop_border(event.position, cx) {
cx.notify();
return;
}
if let Some(grip) = self.grip.take() {
let to = self.doc_point(event.position);
log::info!("lok: handle released");
@@ -365,7 +382,7 @@ impl LokView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.dragging || self.grip.is_some() {
if self.dragging || self.grip.is_some() || self.holds_border() {
self.on_mouse_up(event, window, cx);
}
}
@@ -382,6 +399,10 @@ impl LokView {
cx.notify();
return;
}
if self.move_border(event.position) {
cx.notify();
return;
}
if self.dragging && event.pressed_button == Some(MouseButton::Left) {
self.mouse(2, event.position, 1, 1, &event.modifiers);
} else if event.pressed_button.is_none() {
@@ -391,7 +412,17 @@ impl LokView {
}
pub(super) fn on_copy(&mut self, _: &Copy, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: false });
self.copy(false);
}
/// Copies the selection out, or cuts it.
pub fn copy(&mut self, cut: bool) {
self.send(Input::Copy { cut });
}
/// Pastes the system clipboard.
pub fn paste(&mut self) {
self.send(Input::Paste);
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
@@ -410,11 +441,11 @@ impl LokView {
}
pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: true });
self.copy(true);
}
pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Paste);
self.paste();
}
/// Pastes values or formulas alone (TABLE-08).
+10 -3
View File
@@ -9,7 +9,8 @@ use std::{
use futures::{StreamExt, channel::mpsc, select_biased};
use gpui::Context;
use super::{Geom, LokError, LokView, Reported, TILE_TWIPS, input::Input};
use super::input::{Back, Input};
use super::{Geom, LokError, LokView, Reported, TILE_TWIPS};
use crate::{Doc, Tile, Worker};
/// An input slower than this is logged as a warning.
@@ -24,7 +25,7 @@ pub(super) struct Job {
/// What came back from the lane.
enum Landed {
/// Sequence, whether it edits, whether it switched part, outcome.
Input(u64, bool, bool, (io::Result<Option<String>>, Option<u64>)),
Input(u64, bool, bool, (io::Result<Option<Back>>, Option<u64>)),
Tile(Job, io::Result<Tile>),
}
@@ -111,7 +112,13 @@ impl LokView {
}
self.seen_revision(now.unwrap_or(0));
match outcome {
Ok(Some(copied)) => self.copied = Some(copied),
Ok(Some(Back::Copied(copied))) => self.copied = Some(copied),
Ok(Some(Back::Shown(part))) if part != self.part => {
// The worker turned in order.
self.part = part;
self.invalidate(cx);
}
Ok(Some(Back::Shown(_))) => {}
Ok(None) => {}
Err(error) => {
log::error!("lok: input failed ({:?})", error.kind());
+5 -4
View File
@@ -1,6 +1,6 @@
//! What the view knows, for apps to read.
use gpui::FocusHandle;
use gpui::{FocusHandle, Pixels, Point};
use super::LokView;
use crate::Rect;
@@ -91,9 +91,10 @@ impl LokView {
&self.fonts
}
/// Takes the context menu the kernel last offered.
pub fn take_menu(&mut self) -> Option<String> {
self.menu.take()
/// Takes the kernel's last menu and the click that asked.
pub fn take_menu(&mut self) -> Option<(String, Option<Point<Pixels>>)> {
let menu = self.menu.take()?;
Some((menu, self.right_at.take()))
}
/// The paragraph's own tab stops, in 1/100 mm.
+32 -3
View File
@@ -2,8 +2,8 @@
use ely_gpui_component::theme::ActiveTheme;
use gpui::{
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, Edges, ElementInputHandler,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render, Role,
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, DispatchPhase, Edges,
ElementInputHandler, InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, Render, Role,
StatefulInteractiveElement, Styled, Window, canvas, div, fill, outline, px, quad, size,
transparent_black,
};
@@ -24,6 +24,16 @@ pub fn scheme(dark: bool) -> &'static str {
if dark { DARK } else { LIGHT }
}
/// A page's colour; the kernel's Ely schemes share it.
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
let theme = cx.theme();
if theme.is_dark() {
theme.colors.surface
} else {
theme.colors.paper
}
}
impl LokView {
/// Paints with the app's light or dark scheme.
pub(super) fn follow_theme(&mut self, dark: bool) {
@@ -59,7 +69,7 @@ impl Render for LokView {
.aria_value(self.a11y.text.clone())
.key_context(CONTEXT)
.track_focus(&self.focus)
.cursor(self.pointer)
.cursor(self.border_pointer().unwrap_or(self.pointer))
.size_full()
.overflow_hidden()
.bg(canvas_color)
@@ -164,6 +174,9 @@ impl Render for LokView {
));
}
}
if let Some(guide) = painted.guide {
window.paint_quad(fill(guide, frame_color));
}
if let Some(caret) = painted.caret.filter(|_| focus.is_focused(window))
{
let caret = Bounds {
@@ -173,6 +186,19 @@ impl Render for LokView {
window.paint_quad(fill(caret, caret_color));
}
});
if painted.guide.is_some() {
// A held border follows the pointer out of the view.
let this = this.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, app| {
if phase == DispatchPhase::Bubble {
this.update(app, |view, cx| {
if view.move_border(event.position) {
cx.notify();
}
});
}
});
}
window.handle_input(&focus, ElementInputHandler::new(bounds, this), app);
},
)
@@ -196,6 +222,8 @@ pub(super) struct Painted {
pub(super) frame: Option<(Bounds<Pixels>, Vec<Bounds<Pixels>>)>,
/// The references of a formula being typed.
pub(super) marks: Vec<(Bounds<Pixels>, gpui::Hsla)>,
/// A held table border's guide line.
pub(super) guide: Option<Bounds<Pixels>>,
}
impl LokView {
@@ -357,6 +385,7 @@ impl LokView {
cell: self.cell.cursor.map(rect),
frame,
marks: self.marks(),
guide: self.guide(),
}
}
}
+2
View File
@@ -1,10 +1,12 @@
use super::*;
mod borders;
mod clip;
mod commands;
mod fit;
mod input;
mod large;
mod show_borders;
mod theme;
use gpui::{App, Bounds, Entity, point, size};
+301
View File
@@ -0,0 +1,301 @@
use super::*;
use crate::client::tests::poll_until;
use gpui::{CursorStyle, MouseButton, MouseDownEvent, MouseUpEvent, TestAppContext};
const TABLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// The view takes the cursor's table the kernel reports.
fn borders_read(view: &mut LokView) -> crate::table::Table {
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&doc, 0, 1027).expect("Right moves the cursor");
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
let table = seen.table.expect("the cursor's table");
let now = worker.now(&doc).expect("the revision");
drop(worker);
view.doc = now;
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
#[test]
fn a_dragged_border_moves_once_where_its_guide_shows() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (line, moved) = cx.update(|app| {
view.update(app, |view, cx| {
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let line = columns.offset + columns.inner[0].position;
let middle = rows.offset + (rows.left + rows.right) / 2;
let on = view.window_point((line, middle));
view.move_border(on);
assert_eq!(view.border_pointer(), Some(CursorStyle::ResizeColumn), "a hovered line asks to resize");
assert!(view.hold_border(on), "the line is held");
assert!(view.drop_border(on, cx), "let go in place");
assert!(view.hold_border(on));
let end = on + gpui::point(px(40.0), px(3.0));
assert!(view.move_border(end));
let guide = view.guide().expect("a guide line");
let to = view.doc_point(guide.origin).0;
assert!(to > line, "the guide follows the pointer: {line} to {to}");
assert!(view.drop_border(end, cx), "the drop runs");
assert!(!view.holds_border());
(line, to - line)
})
});
for _ in 0..80 {
cx.executor().tick();
}
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
});
let table = seen.table.expect("still in the table");
let landed = table.columns.offset + table.columns.inner[0].position;
assert!((landed - line - moved).abs() <= 1, "the kernel lands it at the guide: {line} + {moved} vs {landed}");
});
}
#[test]
fn a_held_border_keeps_its_table_and_a_newer_read_refuses_it() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let line = cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
view.update(app, |view, cx| {
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let line = columns.offset + columns.inner[0].position;
let on = view.window_point((line, rows.offset + (rows.left + rows.right) / 2));
assert!(view.hold_border(on), "the line is held");
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&doc, 'a' as u32, 512).expect("a types");
view.doc = worker.now(&doc).expect("the revision");
drop(worker);
let mut gone = table.clone();
gone.columns.inner.clear();
view.read_borders(Some(gone), view.doc.rev);
let end = on + gpui::point(px(40.0), px(0.0));
assert!(view.move_border(end), "it moves by its own table");
assert!(view.guide().is_some(), "its guide still shows");
assert!(view.drop_border(end, cx), "the drop runs");
line
})
});
for _ in 0..60 {
for _ in 0..20 {
cx.executor().tick();
}
if !errors.borrow().is_empty() {
break;
}
}
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&view.doc, 0, 1027).expect("Right moves the cursor");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| seen.table.is_some());
let table = seen.table.expect("still in the table");
assert_eq!(table.columns.offset + table.columns.inner[0].position, line, "the old drag never lands");
});
}
#[test]
fn a_click_in_a_short_row_reaches_the_document() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let window = cx.add_window(|_, _| super::input::Blank);
cx.update_window(window.into(), |_, window, app| {
view.update(app, |view, cx| {
view.set_zoom(0.5, cx);
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let x = columns.offset + (columns.left + columns.inner[0].position) / 2;
let middle = view.window_point((x, rows.offset + (rows.inner[0].position + rows.right) / 2));
let line = view.window_point((x, rows.offset + rows.inner[0].position));
assert!(f32::from(line.y - middle.y).abs() < 6.0, "the row is shorter than two reaches");
let down = |position| MouseDownEvent {
button: MouseButton::Left,
position,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
};
let up = MouseUpEvent { button: MouseButton::Left, position: middle, modifiers: Default::default(), click_count: 1 };
view.on_mouse_down(&down(middle), window, cx);
assert!(!view.holds_border(), "the row's middle goes to the document");
view.on_mouse_up(&up, window, cx);
view.on_mouse_down(&down(line), window, cx);
assert!(view.holds_border(), "the line itself is held");
assert!(view.drop_border(line, cx));
})
})
.expect("the window lives");
}
/// The batch whose table has `count` inner columns.
fn table_batch(view: &LokView, count: usize) -> Events {
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.inner.len() == count)
});
assert_eq!(seen.table.as_ref().map(|table| table.columns.inner.len()), Some(count));
seen
}
#[test]
fn a_late_batch_keeps_its_own_revision() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let before = cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
view.update(app, |view, cx| {
borders_read(view);
let old = table_batch(view, 1);
let table = old.table.clone().expect("the old table");
let now = {
let mut worker = view.worker.lock().expect("the worker lock");
worker.run(&view.doc, "table.column_right", &[]).expect("a column goes in");
worker.now(&view.doc).expect("the revision")
};
let before = table_batch(view, 2).table.expect("the new table").columns.inner[0].position;
view.seen_revision(now.rev);
view.apply_batch(old, None, cx);
let on = view.window_point((table.columns.offset + table.columns.inner[0].position, table.rows.offset + 100));
assert!(view.hold_border(on), "the old table's line is held");
let end = on + gpui::point(px(20.0), px(0.0));
assert!(view.drop_border(end, cx), "the drop runs");
before
})
});
for _ in 0..60 {
for _ in 0..20 {
cx.executor().tick();
}
if !errors.borrow().is_empty() {
break;
}
}
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
let after = cx.update(|app| view.update(app, |view, _| table_batch(view, 2).table.expect("the table").columns.inner[0].position));
assert_eq!(after, before, "the old read never moves the new table");
}
/// The real view, narrower than its window.
struct Narrow(gpui::Entity<LokView>);
impl gpui::Render for Narrow {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled};
gpui::div().w(px(450.0)).h(px(300.0)).child(self.0.clone())
}
}
#[test]
fn a_border_let_go_outside_the_view_lands_there() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
vcx.run_until_parked();
let (line, on) = vcx.update(|_, app| {
view.update(app, |view, cx| {
let table = borders_read(view);
cx.notify();
let line = table.columns.offset + table.columns.inner[0].position;
(line, view.window_point((line, table.rows.offset + 100)))
})
});
vcx.run_until_parked();
assert!(on.x > px(380.0) && on.x < px(440.0), "the line sits near the view's edge: {on:?}");
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held");
let inside = gpui::point(px(445.0), on.y);
vcx.simulate_mouse_move(inside, MouseButton::Left, Default::default());
let at = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| view.read(app).guide().expect("a guide").origin.x);
let held = at(vcx);
let outside = gpui::point(px(490.0), on.y);
vcx.simulate_mouse_move(outside, MouseButton::Left, Default::default());
let out = at(vcx);
assert!(out > held, "the guide follows out of the view: {held:?} to {out:?}");
let wanted = vcx.update(|_, app| view.read(app).doc_point(gpui::point(out, on.y)).0);
vcx.simulate_mouse_up(outside, MouseButton::Left, Default::default());
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
vcx.run_until_parked();
let landed = vcx.update(|_, app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
});
let table = seen.table.expect("still in the table");
table.columns.offset + table.columns.inner[0].position
});
assert!((landed - wanted).abs() <= 1, "it lands at the release: {landed} vs {wanted}");
}
#[test]
fn a_still_click_beside_a_border_sends_nothing() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
vcx.run_until_parked();
let on = vcx.update(|_, app| {
view.update(app, |view, cx| {
let table = borders_read(view);
cx.notify();
let line = table.columns.offset + table.columns.inner[0].position;
view.window_point((line, table.rows.offset + 100)) + gpui::point(px(3.0), px(0.0))
})
});
vcx.run_until_parked();
let sent = vcx.update(|_, app| view.read(app).sent());
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held off its centre");
vcx.simulate_mouse_up(on, MouseButton::Left, Default::default());
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
assert_eq!(vcx.update(|_, app| view.read(app).sent()), sent, "a still click sends no command");
}
+1 -1
View File
@@ -60,7 +60,7 @@ fn typing_and_composing_reach_the_document() {
assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]);
}
struct Blank;
pub(super) struct Blank;
impl gpui::Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
@@ -0,0 +1,172 @@
use super::*;
use crate::client::tests::{poll_until, show::DECK};
use crate::table::Table;
use gpui::{Pixels, Point, TestAppContext};
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// A line's place along its axis, in twips.
type Place = fn(&Table) -> i32;
/// A new table on the slide, read into the view.
fn table_in(view: &mut LokView, (columns, rows): (&str, &str)) -> Table {
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.run(&doc, "table.insert", &[columns, rows]).expect("a table");
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
assert!(seen.faults.is_empty(), "{:?}", seen.faults);
let table = seen.table.expect("the selected table");
view.doc = worker.now(&doc).expect("the revision");
drop(worker);
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
/// The view's table once `place` moves off `from`.
fn moved(view: &mut LokView, place: Place, from: i32) -> Table {
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| place(table) != from)
});
let table = seen.table.expect("still the table");
view.doc = worker.now(&view.doc).expect("the revision");
drop(worker);
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
/// The last drag's place: before, guide, landed, undone.
fn drag(size: (&str, &str), place: Place, upright: bool, bys: &[Point<Pixels>]) -> Option<[i32; 4]> {
let install = crate::client::tests::install()?;
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, size)));
let mut last = [0; 3];
for &by in bys {
let (before, guide) = cx.update(|app| {
view.update(app, |view, cx| {
let at = place(&table);
let across = if upright { &table.rows } else { &table.columns };
// A quarter along: halfway may cross another line.
let along = across.offset + across.left + (across.right - across.left) / 4;
let on = view.window_point(if upright { (at, along) } else { (along, at) });
assert!(view.hold_border(on), "the line is held");
assert!(view.move_border(on + by));
let guide = view.doc_point(view.guide().expect("a guide").origin);
assert!(view.drop_border(on + by, cx), "the drop runs");
(at, if upright { guide.0 } else { guide.1 })
})
});
for _ in 0..80 {
cx.executor().tick();
}
table = cx.update(|app| view.update(app, |view, _| moved(view, place, before)));
last = [before, guide, place(&table)];
}
let undone = cx.update(|app| {
view.update(app, |view, _| {
view.worker.lock().expect("the worker lock").run(&view.doc, "edit.undo", &[]).expect("undoes");
place(&moved(view, place, last[2]))
})
});
Some([last[0], last[1], last[2], undone])
}
/// Lands at its guide; one undo puts it back.
#[track_caller]
fn lands([before, guide, landed, undone]: [i32; 4]) {
assert!((landed - guide).abs() <= 2, "{before} to the guide {guide}, landed {landed}");
assert!((undone - before).abs() <= 1, "one undo: {undone} vs {before}");
}
#[test]
fn an_inner_line_lands_at_its_guide() {
let place: Place = |t| t.columns.offset + t.columns.inner[0].position;
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went right");
lands(moved);
}
#[test]
fn the_left_edge_narrows_the_first_column_and_moves_the_table() {
let place: Place = |t| t.columns.offset + t.columns.left;
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went right");
lands(moved);
}
#[test]
fn a_one_row_bottom_grows() {
let place: Place = |t| t.rows.offset + t.rows.right;
let Some(moved) = drag(("2", "1"), place, false, &[gpui::point(px(0.0), px(40.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went down");
lands(moved);
}
#[test]
fn a_grown_bottom_stops_at_the_last_rows_minimum() {
let place: Place = |t| t.rows.offset + t.rows.right;
let Some(moved) = drag(("2", "2"), place, false, &[gpui::point(px(0.0), px(60.0)), gpui::point(px(0.0), px(-200.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] < moved[0] && moved[1] > moved[0] - 3000, "the guide stops at the minimum: {moved:?}");
lands(moved);
}
#[test]
fn a_one_column_right_edge_stops_at_the_columns_minimum() {
let place: Place = |t| t.columns.offset + t.columns.right;
let Some(moved) = drag(("1", "2"), place, true, &[gpui::point(px(-600.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0] - 9000, "the guide stops at the minimum: {moved:?}");
lands(moved);
}
#[test]
fn a_still_press_beside_a_minimum_column_sends_nothing() {
let Some(install) = crate::client::tests::install() else {
return eprintln!("SKIP: no pinned LOK build");
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let right: Place = |t| t.columns.offset + t.columns.right;
let quarter = |t: &Table| t.rows.offset + t.rows.left + (t.rows.right - t.rows.left) / 4;
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, ("2", "2"))));
// Its minimum width drops once it narrows; shrink twice.
for _ in 0..2 {
let before = cx.update(|app| {
view.update(app, |view, cx| {
let on = view.window_point((right(&table), quarter(&table)));
let to = on + gpui::point(px(-600.0), px(0.0));
assert!(view.hold_border(on) && view.move_border(to) && view.drop_border(to, cx));
right(&table)
})
});
for _ in 0..80 {
cx.executor().tick();
}
table = cx.update(|app| view.update(app, |view, _| moved(view, right, before)));
}
let sent = cx.update(|app| {
view.update(app, |view, cx| {
let line = table.columns.inner[0];
assert_eq!(line.position, line.max, "the right column is at its minimum");
let on = view.window_point((table.columns.offset + line.position, quarter(&table)));
let sent = view.sent();
assert!(view.hold_border(on));
assert!(view.drop_border(on, cx));
sent
})
});
for _ in 0..80 {
cx.executor().tick();
}
assert_eq!(cx.update(|app| view.read(app).sent()), sent, "a still press sends no command");
}
+199
View File
@@ -0,0 +1,199 @@
//! The wheel and Up, Down turn slides (SHOW-01).
use std::time::{Duration, Instant};
use gpui::{Keystroke, Modifiers, ScrollDelta, ScrollWheelEvent, TouchPhase};
use super::LokView;
use super::input::{Input, vcl_key};
/// Trackpad pixels one turn takes.
const SWIPE: f32 = 50.0;
/// A pause this long ends a gesture without phases.
const PAUSE: Duration = Duration::from_millis(200);
/// Lines one wheel notch scrolls on this platform.
pub fn notch() -> f32 {
#[cfg(windows)]
{
use windows_sys::Win32::UI::WindowsAndMessaging::{SPI_GETWHEELSCROLLLINES, SystemParametersInfoW};
let mut lines = 3u32;
// Same setting gpui scales the wheel by.
unsafe { SystemParametersInfoW(SPI_GETWHEELSCROLLLINES, 0, (&raw mut lines).cast(), 0) };
lines as f32
}
#[cfg(target_os = "macos")]
{
1.0
}
// gpui's X11 and Wayland scroll three lines a notch.
#[cfg(not(any(windows, target_os = "macos")))]
{
3.0
}
}
/// The wheel's progress towards a turn.
#[derive(Default)]
pub(super) struct Turning {
/// This gesture's travel, in turns.
travel: f32,
/// This gesture already turned.
spent: bool,
/// The platform began a gesture it has not ended.
held: bool,
/// Fractional lines in this run: a touchpad, not a wheel.
smooth: bool,
last: Option<Instant>,
}
impl Turning {
/// The turn a wheel event makes; 0 for none.
pub(super) fn wheel(&mut self, delta: ScrollDelta, phase: TouchPhase, now: Instant, notch: f32) -> i32 {
let paused = self.last.is_none_or(|last| now.duration_since(last) >= PAUSE);
self.last = Some(now);
// X11 lines mix wheels and touchpads; they carry no phase.
let (travel, phase) = match delta {
ScrollDelta::Lines(_) if notch <= 0.0 => return 0,
ScrollDelta::Lines(lines) => (lines.y / notch, None),
ScrollDelta::Pixels(pixels) => (f32::from(pixels.y) / SWIPE, Some(phase)),
};
// Phases bound a gesture; without them, a pause does.
if phase == Some(TouchPhase::Started) || paused && !self.held {
(self.travel, self.spent, self.smooth) = (0.0, false, false);
}
let whole = travel.round();
if phase.is_none() && (whole == 0.0 || (travel - whole).abs() >= 1e-3) {
self.smooth = true;
}
// Whole wheel notches each turn, until fractions show a touchpad.
if phase.is_none() && !self.smooth {
(self.travel, self.spent, self.held) = (0.0, true, false);
return -(whole as i32);
}
self.held = match phase {
Some(TouchPhase::Started) => true,
Some(TouchPhase::Moved) => self.held,
Some(TouchPhase::Ended | TouchPhase::Cancelled) | None => false,
};
// One gesture turns once, momentum included.
if self.spent {
return 0;
}
self.travel += travel;
if self.travel.abs() < 1.0 - 1e-3 {
return 0;
}
self.spent = true;
// Content moving down shows the part before.
-(self.travel.signum() as i32)
}
}
impl LokView {
/// The wheel and Up, Down turn parts from now on.
pub fn turn_parts(&mut self) {
self.turning.get_or_insert_default();
}
/// Queues Up, Down; the worker turns if nothing is picked.
pub(super) fn turn_key(&mut self, keystroke: &Keystroke) -> bool {
if self.turning.is_none() || keystroke.modifiers != Modifiers::none() {
return false;
}
let step = match keystroke.key.as_str() {
"down" => 1,
"up" => -1,
_ => return false,
};
let Some(key) = vcl_key(keystroke) else {
return false;
};
self.send(Input::Turn { step, key: Some(key) });
true
}
/// The wheel turns while the whole page shows.
pub(super) fn turn_wheel(&mut self, event: &ScrollWheelEvent) -> bool {
let (width, height) = self.extent();
// A pixel of slack for rounding.
let whole = self.geom.px_of(width) <= self.viewport.0 + 1.0
&& self.geom.px_of(height) <= self.viewport.1 + 1.0;
let Some(turning) = self.turning.as_mut().filter(|_| whole) else {
return false;
};
let step = turning.wheel(event.delta, event.touch_phase, Instant::now(), notch());
if step != 0 {
// In order with the input around it.
self.send(Input::Turn { step, key: None });
}
true
}
}
#[cfg(test)]
mod tests {
use gpui::{ScrollDelta, TouchPhase, point, px};
use super::{PAUSE, Turning};
const NOTCH: f32 = 3.0;
fn lines(y: f32) -> ScrollDelta {
ScrollDelta::Lines(point(0.0, y))
}
fn pixels(y: f32) -> ScrollDelta {
ScrollDelta::Pixels(point(px(0.0), px(y)))
}
#[test]
fn each_notch_with_a_pause_turns_once() {
let (mut turning, now) = (Turning::default(), std::time::Instant::now());
let mut wheel = |y, at| turning.wheel(lines(y), TouchPhase::Moved, at, NOTCH);
assert_eq!(wheel(-3.0, now), 1, "Windows and Linux scroll three lines a notch");
assert_eq!(wheel(-3.0, now + PAUSE), 1);
assert_eq!(wheel(3.0, now + PAUSE * 2), -1);
assert_eq!(wheel(-6.0, now + PAUSE * 3), 2, "two notches in one event");
assert_eq!(wheel(-3.0, now + PAUSE * 3), 1, "rapid notches each turn");
assert_eq!(wheel(-3.0, now + PAUSE * 3), 1);
assert_eq!(wheel(-1.0, now + PAUSE * 4), 0, "a third of a notch");
let mut mac = Turning::default();
assert_eq!(mac.wheel(lines(-1.0), TouchPhase::Moved, now, 1.0), 1, "macOS scrolls a line a notch");
}
#[test]
fn a_stream_of_lines_turns_once() {
let (mut turning, now) = (Turning::default(), std::time::Instant::now());
// A whole step, then fractions: no more turns once smooth.
let stream = [-3.0, -0.4, -0.4, -0.4, -0.4, -0.4, -0.4, -0.4, -0.4, -3.0];
let turned: i32 = stream.iter().map(|&y| turning.wheel(lines(y), TouchPhase::Moved, now, NOTCH)).sum();
assert_eq!(turned, 1, "one swipe turns once");
let mut turning = Turning::default();
let turned: i32 = (0..30).map(|_| turning.wheel(lines(-0.4), TouchPhase::Moved, now, NOTCH)).sum();
assert_eq!(turned, 1, "fractions add up once");
assert_eq!(turning.wheel(lines(-3.0), TouchPhase::Moved, now + PAUSE, NOTCH), 1, "a pause ends it");
}
#[test]
fn a_swipe_turns_once_until_the_next() {
let (mut turning, now) = (Turning::default(), std::time::Instant::now());
let mut swipe = |y, phase, at| turning.wheel(pixels(y), phase, at, NOTCH);
assert_eq!(swipe(-30.0, TouchPhase::Started, now), 0);
assert_eq!(swipe(-30.0, TouchPhase::Moved, now), 1);
// A pause with fingers down is the same gesture.
assert_eq!(swipe(-60.0, TouchPhase::Moved, now + PAUSE * 2), 0);
assert_eq!(swipe(0.0, TouchPhase::Ended, now + PAUSE * 2), 0);
// Momentum after the turn turns nothing more.
for _ in 0..20 {
assert_eq!(swipe(-80.0, TouchPhase::Moved, now + PAUSE * 2), 0);
}
assert_eq!(swipe(60.0, TouchPhase::Started, now + PAUSE * 2), -1);
assert_eq!(swipe(0.0, TouchPhase::Ended, now + PAUSE * 2), 0);
// Without phases a pause ends a gesture (Wayland).
let later = now + PAUSE * 4;
assert_eq!(swipe(60.0, TouchPhase::Moved, later), -1);
assert_eq!(swipe(60.0, TouchPhase::Moved, later), 0);
assert_eq!(swipe(60.0, TouchPhase::Moved, later + PAUSE), -1);
}
}
+2 -2
View File
@@ -65,9 +65,9 @@ impl LokView {
cx.notify();
}
/// Ctrl (macOS: Cmd) and the wheel zoom; else scroll.
/// Ctrl (macOS: Cmd) wheel zooms; else it turns or scrolls.
pub(super) fn on_wheel(&mut self, event: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
if !self.wheel_zoom(event, cx) {
if !self.wheel_zoom(event, cx) && !self.turn_wheel(event) {
self.on_scroll(event, window, cx);
}
}
+1
View File
@@ -218,6 +218,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
}
}),
tag::POST_KEY => input::key(unpack),
tag::TURN => input::turn(unpack),
tag::POST_MOUSE => input::mouse(unpack),
tag::GRAPHIC => input::graphic(unpack),
tag::POST_TEXT => input::text(unpack),
+1 -1
View File
@@ -82,5 +82,5 @@ fn pack_events(batch: Events) -> Vec<u8> {
.i64(signed(a11y.caret))
.i64(signed(start))
.i64(signed(end));
batch.cell.pack(pack).bytes()
crate::table::Table::pack(batch.table.as_ref(), batch.cell.pack(pack)).bytes()
}
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more